Files
sub2api/frontend/src/views/user/PaymentResultView.vue
erio e761d38fd1 fix(payment): integrate recharge fee rate in order flow and fix UI display
Backend:
- Use cfg.RechargeFeeRate in order creation instead of hardcoded 0
- Remove dead getFeeRate stub method
- All amounts computed server-side: order_amount, pay_amount, fee_rate

Frontend - PaymentView:
- Read recharge_fee_rate from checkout-info API (not per-method)
- Show fee breakdown only when fee_rate > 0
- Show credited amount only when multiplier ≠ 1

Frontend - Order display (user + admin):
- Fix fee_rate * 100 bug (fee_rate is already a percentage)
- OrderTable: show pay_amount as primary, fee/credited as sub-lines
- AdminOrderDetail: full breakdown (base/fee/paid/credited)
- AdminRefundDialog: label "到账金额" for clarity
- PaymentResultView: show pay_amount with fee info

Types + i18n:
- Add recharge_fee_rate to CheckoutInfoResponse
- Add fee_rate to CreateOrderResult
- Add translations: creditedAmount, fee, baseAmount, includedInPayAmount
2026-04-15 01:27:24 +08:00

174 lines
7.8 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
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.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ order.pay_amount.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="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.orders.includedInPayAmount') }}</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('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.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('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>