-
+
+
+
+
+ {{ t('payment.admin.subscriptionCnyPayPreview', { amount: subscriptionCnyPreview.amount }) }}
+
+ {{ t('payment.admin.subscriptionCnyPayPreviewWithFee', { feeRate: subscriptionCnyPreview.feeRate, total: subscriptionCnyPreview.total }) }}
+
+
+
@@ -81,7 +90,9 @@ import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment'
+import type { AdminPaymentConfig } from '@/api/admin/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
+import { formatPaymentAmount } from '@/components/payment/currency'
import type { SubscriptionPlan } from '@/types/payment'
import type { AdminGroup } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
@@ -94,6 +105,7 @@ const props = defineProps<{
show: boolean
plan: SubscriptionPlan | null
groups: AdminGroup[]
+ paymentConfig?: AdminPaymentConfig | null
}>()
const emit = defineEmits<{
@@ -129,6 +141,31 @@ const selectedGroupInfo = computed(() => {
return props.groups.find(g => g.id === planForm.group_id) || null
})
+function roundCnyAmount(value: number): number {
+ return Math.round(value * 100) / 100
+}
+
+function ceilCnyAmount(value: number): number {
+ return Math.ceil(value * 100) / 100
+}
+
+const subscriptionCnyPreview = computed(() => {
+ const price = Number(planForm.price) || 0
+ const rate = Number(props.paymentConfig?.subscription_usd_to_cny_rate) || 0
+ if (price <= 0 || rate <= 0) return null
+
+ const amount = roundCnyAmount(price * rate)
+ const feeRate = Number(props.paymentConfig?.recharge_fee_rate) || 0
+ const fee = feeRate > 0 ? ceilCnyAmount((amount * feeRate) / 100) : 0
+ const total = feeRate > 0 ? roundCnyAmount(amount + fee) : amount
+
+ return {
+ amount: formatPaymentAmount(amount, 'CNY'),
+ feeRate,
+ total: formatPaymentAmount(total, 'CNY'),
+ }
+})
+
// Reset form when dialog opens
watch(() => props.show, (visible) => {
if (!visible) return
diff --git a/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts b/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts
new file mode 100644
index 0000000000..9c31e7c176
--- /dev/null
+++ b/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PlanEditDialog from '../PlanEditDialog.vue'
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string, params?: Record
) => {
+ if (key === 'payment.admin.subscriptionCnyPayPreview') return `preview ${params?.amount}`
+ if (key === 'payment.admin.subscriptionCnyPayPreviewWithFee') return `fee ${params?.feeRate} ${params?.total}`
+ return key
+ },
+ }),
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showError: vi.fn(),
+ showSuccess: vi.fn(),
+ }),
+}))
+
+vi.mock('@/api/admin/payment', () => ({
+ adminPaymentAPI: {
+ createPlan: vi.fn(),
+ updatePlan: vi.fn(),
+ },
+}))
+
+function mountDialog(paymentConfig: Record | null) {
+ return mount(PlanEditDialog, {
+ props: {
+ show: true,
+ plan: null,
+ groups: [],
+ paymentConfig,
+ },
+ global: {
+ stubs: {
+ BaseDialog: {
+ props: ['show'],
+ template: '
',
+ },
+ Select: true,
+ Icon: true,
+ GroupBadge: true,
+ },
+ },
+ })
+}
+
+describe('PlanEditDialog subscription CNY payment preview', () => {
+ it('shows CNY channel charge using the configured subscription rate and fee', async () => {
+ const wrapper = mountDialog({
+ subscription_usd_to_cny_rate: 7.15,
+ recharge_fee_rate: 2.5,
+ })
+
+ await wrapper.find('input[type="number"]').setValue('9.99')
+
+ expect(wrapper.text()).toContain('preview')
+ expect(wrapper.text()).toContain('¥71.43')
+ expect(wrapper.text()).toContain('fee 2.5')
+ expect(wrapper.text()).toContain('¥73.22')
+ })
+
+ it('hides the preview when the subscription rate is not configured', async () => {
+ const wrapper = mountDialog({
+ subscription_usd_to_cny_rate: 0,
+ recharge_fee_rate: 2.5,
+ })
+
+ await wrapper.find('input[type="number"]').setValue('9.99')
+
+ expect(wrapper.text()).not.toContain('preview')
+ expect(wrapper.text()).not.toContain('¥71.43')
+ })
+})