diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts index 9bab627218..1d4305948e 100644 --- a/frontend/src/api/admin/payment.ts +++ b/frontend/src/api/admin/payment.ts @@ -25,6 +25,7 @@ export interface AdminPaymentConfig { balance_disabled: boolean balance_recharge_multiplier: number subscription_usd_to_cny_rate: number + recharge_fee_rate: number load_balance_strategy: string product_name_prefix: string product_name_suffix: string @@ -44,6 +45,7 @@ export interface UpdatePaymentConfigRequest { balance_disabled?: boolean balance_recharge_multiplier?: number subscription_usd_to_cny_rate?: number + recharge_fee_rate?: number load_balance_strategy?: string product_name_prefix?: string product_name_suffix?: string diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 749d2dcbcd..b777cefab5 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -7447,6 +7447,8 @@ export default { deletePlanConfirm: 'Are you sure you want to delete this plan?', originalPrice: 'Original Price', price: 'Price', + subscriptionCnyPayPreview: 'CNY channel charge preview: {amount}', + subscriptionCnyPayPreviewWithFee: '({feeRate}% fee included: {total})', validityDays: 'Validity (days)', validityUnit: 'Validity Unit', sortOrder: 'Sort Order', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a0b1337796..79237b3c26 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -7625,6 +7625,8 @@ export default { deletePlanConfirm: '确定要删除此套餐吗?', originalPrice: '原价', price: '价格', + subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}', + subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})', validityDays: '有效期(天)', validityUnit: '有效期单位', sortOrder: '排序', diff --git a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue index c2fc26fe90..f99c33cd2f 100644 --- a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue @@ -67,7 +67,7 @@ - + @@ -78,6 +78,7 @@ import { ref, computed, onMounted } 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 { extractI18nErrorMessage } from '@/utils/apiError' import adminAPI from '@/api/admin' import type { SubscriptionPlan } from '@/types/payment' @@ -97,6 +98,7 @@ const appStore = useAppStore() // ==================== Groups ==================== const groups = ref([]) +const paymentConfig = ref(null) async function loadGroups() { try { @@ -104,6 +106,13 @@ async function loadGroups() { } catch { /* ignore */ } } +async function loadPaymentConfig() { + try { + const res = await adminPaymentAPI.getConfig() + paymentConfig.value = res.data + } catch { /* preview only */ } +} + function getGroup(id: number): AdminGroup | undefined { return groups.value.find(g => g.id === id) } @@ -181,6 +190,7 @@ async function handleDeletePlan() { onMounted(() => { loadGroups() + loadPaymentConfig() loadPlans() }) diff --git a/frontend/src/views/admin/orders/PlanEditDialog.vue b/frontend/src/views/admin/orders/PlanEditDialog.vue index acc70bef16..92b6574734 100644 --- a/frontend/src/views/admin/orders/PlanEditDialog.vue +++ b/frontend/src/views/admin/orders/PlanEditDialog.vue @@ -35,7 +35,16 @@
-
+
+ + +

+ {{ 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') + }) +})