fix(admin): 订单金额币种符号读取 currency 字段

This commit is contained in:
wucm667
2026-06-25 16:23:45 +08:00
parent 00d68ff6df
commit 55242ffac1
14 changed files with 506 additions and 42 deletions
@@ -14,12 +14,12 @@
</template>
<template #cell-pay_amount="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">¥{{ value.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol(row) }}{{ value.toFixed(2) }}</span>
<span v-if="row.fee_rate > 0" class="ml-1 text-xs text-gray-400" :title="t('payment.orders.fee') + ': ' + row.fee_rate + '%'">
({{ t('payment.orders.fee') }} {{ row.fee_rate }}%)
</span>
<div v-if="row.amount !== row.pay_amount" class="text-xs text-gray-500">
{{ t('payment.orders.creditedAmount') }}: {{ row.order_type === 'balance' ? '$' : '¥' }}{{ row.amount.toFixed(2) }}
{{ t('payment.orders.creditedAmount') }}: {{ creditedAmountSymbol }}{{ row.amount.toFixed(2) }}
</div>
</div>
</template>
@@ -45,6 +45,7 @@ import type { PaymentOrder } from '@/types/payment'
import type { Column } from '@/components/common/types'
import DataTable from '@/components/common/DataTable.vue'
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
import { currencySymbol } from '@/components/payment/currency'
const { t } = useI18n()
@@ -56,6 +57,12 @@ const props = defineProps<{
function formatDate(dateStr: string) { return new Date(dateStr).toLocaleString() }
const creditedAmountSymbol = currencySymbol('USD')
function paymentAmountSymbol(order: PaymentOrder): string {
return currencySymbol(order.currency)
}
const columns = computed((): Column[] => {
const cols: Column[] = [
{ key: 'id', label: t('payment.orders.orderId') },
@@ -45,11 +45,11 @@
</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.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ paidOrder.amount.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-medium text-gray-900 dark:text-white">¥{{ paidOrder.pay_amount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol(paidOrder) }}{{ paidOrder.pay_amount.toFixed(2) }}</span>
</div>
</div>
</div>
@@ -81,6 +81,7 @@ import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import { currencySymbol } from '@/components/payment/currency'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
@@ -111,6 +112,7 @@ const expired = ref(false)
const cancelling = ref(false)
const success = ref(false)
const paidOrder = ref<PaymentOrder | null>(null)
const creditedAmountSymbol = currencySymbol('USD')
let pollTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
@@ -137,6 +139,10 @@ const scanHint = computed(() => {
return ''
})
function paymentAmountSymbol(order: PaymentOrder): string {
return currencySymbol(order.currency)
}
const countdownDisplay = computed(() => {
const m = Math.floor(remainingSeconds.value / 60)
const s = remainingSeconds.value % 60
@@ -22,11 +22,11 @@
</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.order_type === 'balance' ? '$' + paidOrder.amount.toFixed(2) : formatGatewayAmount(paidOrder.amount) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ paidOrder.amount.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-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(paidOrder.pay_amount) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(paidOrder.pay_amount, paidOrder.currency) }}</span>
</div>
</div>
</div>
@@ -129,7 +129,7 @@ import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { currencySymbol, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentOrder } from '@/types/payment'
import Icon from '@/components/icons/Icon.vue'
import QRCode from 'qrcode'
@@ -161,6 +161,7 @@ const remainingSeconds = ref(0)
const cancelling = ref(false)
const paidOrder = ref<PaymentOrder | null>(null)
const paymentCurrency = computed(() => normalizePaymentCurrency(props.currency))
const creditedAmountSymbol = currencySymbol('USD')
const localeCode = computed(() => {
const raw = i18n.locale as unknown
if (typeof raw === 'string') return raw
@@ -214,8 +215,8 @@ const countdownDisplay = computed(() => {
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
})
function formatGatewayAmount(value: number): string {
return formatPaymentAmount(value, paymentCurrency.value, localeCode.value)
function formatGatewayAmount(value: number, currency?: string | null): string {
return formatPaymentAmount(value, currency || paymentCurrency.value, localeCode.value)
}
function isSuccessStatus(status: string | null | undefined): boolean {
@@ -23,11 +23,11 @@
</div>
<div v-if="amount > 0" 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">{{ orderType === 'balance' ? '$' : '¥' }}{{ amount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ amount.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-medium text-gray-900 dark:text-white">¥{{ payAmount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol }}{{ payAmount.toFixed(2) }}</span>
</div>
</div>
</div>
@@ -40,7 +40,7 @@
<div class="card overflow-hidden">
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center">
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
<p class="mt-1 text-3xl font-bold text-white">¥{{ payAmount.toFixed(2) }}</p>
<p class="mt-1 text-3xl font-bold text-white">{{ paymentAmountSymbol }}{{ payAmount.toFixed(2) }}</p>
</div>
</div>
<!-- Stripe Payment Element -->
@@ -64,13 +64,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { computed, ref, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { paymentAPI } from '@/api/payment'
import { useAppStore } from '@/stores'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import { currencySymbol } from '@/components/payment/currency'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
import Icon from '@/components/icons/Icon.vue'
@@ -84,6 +85,7 @@ const props = defineProps<{
orderType?: 'balance' | 'subscription'
publishableKey: string
payAmount: number
currency?: string
}>()
const emit = defineEmits<{ success: []; done: []; back: []; redirect: [orderId: number, payUrl: string] }>()
@@ -101,6 +103,8 @@ const cancelling = ref(false)
const success = ref(false)
const ready = ref(false)
const selectedType = ref('')
const creditedAmountSymbol = currencySymbol('USD')
const paymentAmountSymbol = computed(() => currencySymbol(props.currency))
let stripeInstance: Stripe | null = null
let elementsInstance: StripeElements | null = null
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import PaymentQRDialog from '../PaymentQRDialog.vue'
const pollOrderStatus = vi.hoisted(() => vi.fn())
const cancelOrder = vi.hoisted(() => vi.fn())
const verifyOrder = vi.hoisted(() => vi.fn())
const showError = vi.hoisted(() => vi.fn())
const toCanvas = vi.hoisted(() => vi.fn())
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@/stores/payment', () => ({
usePaymentStore: () => ({
pollOrderStatus,
}),
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError,
}),
}))
vi.mock('@/api/payment', () => ({
paymentAPI: {
cancelOrder,
verifyOrder,
},
}))
vi.mock('qrcode', () => ({
default: {
toCanvas,
},
}))
const paidOrder = {
id: 42,
user_id: 9,
amount: 100,
pay_amount: 108,
currency: 'CNY',
fee_rate: 8,
payment_type: 'alipay',
out_trade_no: 'sub2_202606250001',
status: 'COMPLETED',
order_type: 'subscription',
created_at: '2026-06-25T10:00:00Z',
expires_at: '2099-01-01T10:30:00Z',
refund_amount: 0,
}
describe('PaymentQRDialog currency display', () => {
beforeEach(() => {
vi.useFakeTimers()
pollOrderStatus.mockReset().mockResolvedValue(paidOrder)
cancelOrder.mockReset()
verifyOrder.mockReset()
showError.mockReset()
toCanvas.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('uses order currency for pay_amount and USD for credited amount', async () => {
const wrapper = mount(PaymentQRDialog, {
props: {
show: false,
orderId: 42,
qrCode: '',
expiresAt: '2099-01-01T10:30:00Z',
paymentType: 'alipay',
},
global: {
stubs: {
BaseDialog: {
props: ['show'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
},
Icon: true,
},
},
})
await wrapper.setProps({ show: true })
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(pollOrderStatus).toHaveBeenCalledWith(42)
expect(wrapper.text()).toContain('$100.00')
expect(wrapper.text()).toContain('¥108.00')
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { formatPaymentAmount } from '../currency'
import { currencySymbol, formatPaymentAmount } from '../currency'
describe('formatPaymentAmount', () => {
it('uses the currency default fraction digits', () => {
@@ -8,3 +8,13 @@ describe('formatPaymentAmount', () => {
expect(formatPaymentAmount(100, 'HKD', 'en-US')).toContain('.00')
})
})
describe('currencySymbol', () => {
it('maps common payment currencies and falls back safely', () => {
expect(currencySymbol('USD')).toBe('$')
expect(currencySymbol('cny')).toBe('¥')
expect(currencySymbol('EUR')).toBe('€')
expect(currencySymbol('')).toBe('¥')
expect(currencySymbol('XYZ')).toBe('XYZ')
})
})
@@ -1,10 +1,36 @@
export const DEFAULT_PAYMENT_CURRENCY = 'CNY'
const PAYMENT_CURRENCY_SYMBOLS: Record<string, string> = {
USD: '$',
CNY: '¥',
RMB: '¥',
EUR: '€',
GBP: '£',
JPY: '¥',
HKD: 'HK$',
TWD: 'NT$',
KRW: '₩',
AUD: 'A$',
CAD: 'C$',
SGD: 'S$',
NZD: 'NZ$',
MOP: 'MOP$',
MYR: 'RM',
THB: '฿',
PHP: '₱',
INR: '₹',
}
export function normalizePaymentCurrency(currency?: string | null): string {
const normalized = String(currency || '').trim().toUpperCase()
return /^[A-Z]{3}$/.test(normalized) ? normalized : DEFAULT_PAYMENT_CURRENCY
}
export function currencySymbol(currency?: string | null): string {
const normalized = normalizePaymentCurrency(currency)
return PAYMENT_CURRENCY_SYMBOLS[normalized] || normalized
}
function paymentCurrencyFractionDigits(currency: string): number {
try {
return new Intl.NumberFormat(undefined, {