Files
sub2api/frontend/src/views/user/PaymentResultView.vue
2026-04-21 00:05:09 +08:00

205 lines
9.0 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
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.baseAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ baseAmount.toFixed(2) }}</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">&#165;{{ feeAmount.toFixed(2) }}</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">&#165;{{ order.pay_amount.toFixed(2) }}</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) }}</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">&#165;{{ 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(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, 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, readPaymentRecoverySnapshot } from '@/components/payment/paymentFlow'
import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import type { PaymentOrder } from '@/types/payment'
import { normalizePaymentMethodForDisplay, paymentMethodI18nKey } from './paymentUx'
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'])
/** 充值金额 = pay_amount / (1 + fee_rate/100)fee_rate=0 时等于 pay_amount */
const baseAmount = computed(() => {
if (!order.value || order.value.fee_rate <= 0) return order.value?.pay_amount ?? 0
return Math.round((order.value.pay_amount / (1 + order.value.fee_rate / 100)) * 100) / 100
})
/** 手续费 = pay_amount - baseAmount */
const feeAmount = computed(() => {
if (!order.value || order.value.fee_rate <= 0) return 0
return Math.round((order.value.pay_amount - baseAmount.value) * 100) / 100
})
const isSuccess = computed(() => {
return !!order.value && SUCCESS_STATUSES.has(order.value.status)
})
function normalizedOrderPaymentType(paymentType: string): string {
return normalizePaymentMethodForDisplay(paymentType) || paymentType
}
onMounted(async () => {
const resumeToken = typeof route.query.resume_token === 'string'
? route.query.resume_token
: ''
let orderId = Number(route.query.order_id) || 0
const outTradeNo = String(route.query.out_trade_no || '')
if (!orderId && resumeToken && typeof window !== 'undefined') {
const restored = readPaymentRecoverySnapshot(
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
{ resumeToken },
)
if (restored?.orderId) {
orderId = restored.orderId
}
}
if (!order.value && !orderId && resumeToken) {
try {
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
order.value = result.data
orderId = result.data.id
} catch (_err: unknown) {
// Resume token recovery failed, continue to legacy fallback paths.
}
}
if (!order.value && orderId) {
try {
order.value = await paymentStore.pollOrderStatus(orderId)
} catch (_err: unknown) {
// Order lookup failed, will try legacy fallback below when possible.
}
}
if (!order.value && outTradeNo) {
returnInfo.value = {
outTradeNo,
money: String(route.query.money || ''),
type: String(route.query.type || ''),
tradeStatus: String(route.query.trade_status || ''),
}
try {
const result = await paymentAPI.verifyOrderPublic(outTradeNo)
order.value = result.data
} catch (_err: unknown) {
try {
const result = await paymentAPI.verifyOrder(outTradeNo)
order.value = result.data
} catch (_e: unknown) { /* fall through */ }
}
}
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>