Merge pull request #3755 from wucm667/fix/subscription-usd-cny-rate-opt-in

feat(payment): 套餐编辑页展示订阅 CNY 实扣预览
This commit is contained in:
Wesley Liddick
2026-07-07 08:29:12 +08:00
committed by GitHub
6 changed files with 132 additions and 2 deletions
+2
View File
@@ -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
+2
View File
@@ -7465,6 +7465,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',
+2
View File
@@ -7643,6 +7643,8 @@ export default {
deletePlanConfirm: '确定要删除此套餐吗?',
originalPrice: '原价',
price: '价格',
subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}',
subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})',
validityDays: '有效期(天)',
validityUnit: '有效期单位',
sortOrder: '排序',
@@ -67,7 +67,7 @@
</div>
<!-- Plan Edit Dialog -->
<PlanEditDialog :show="showPlanDialog" :plan="editingPlan" :groups="groups" @close="showPlanDialog = false" @saved="loadPlans" />
<PlanEditDialog :show="showPlanDialog" :plan="editingPlan" :groups="groups" :payment-config="paymentConfig" @close="showPlanDialog = false" @saved="loadPlans" />
<ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" />
</AppLayout>
@@ -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<AdminGroup[]>([])
const paymentConfig = ref<AdminPaymentConfig | null>(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()
})
</script>
@@ -35,7 +35,16 @@
<div><label class="input-label">{{ t('payment.admin.planDescription') }} <span class="text-red-500">*</span></label><textarea v-model="planForm.description" rows="2" class="input" required></textarea></div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.price') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.price" type="number" step="0.01" min="0.01" class="input" required /></div>
<div>
<label class="input-label">{{ t('payment.admin.price') }} <span class="text-red-500">*</span></label>
<input v-model.number="planForm.price" type="number" step="0.01" min="0.01" class="input" required />
<p v-if="subscriptionCnyPreview" class="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400">
{{ t('payment.admin.subscriptionCnyPayPreview', { amount: subscriptionCnyPreview.amount }) }}
<span v-if="subscriptionCnyPreview.feeRate > 0">
{{ t('payment.admin.subscriptionCnyPayPreviewWithFee', { feeRate: subscriptionCnyPreview.feeRate, total: subscriptionCnyPreview.total }) }}
</span>
</p>
</div>
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
@@ -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
@@ -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<string, unknown>) => {
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<string, unknown> | null) {
return mount(PlanEditDialog, {
props: {
show: true,
plan: null,
groups: [],
paymentConfig,
},
global: {
stubs: {
BaseDialog: {
props: ['show'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
},
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')
})
})