mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 08:28:34 +00:00
fix(admin): 订单金额币种符号读取 currency 字段
This commit is contained in:
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user