mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(payment): preview subscription CNY charge in plan editor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -7625,6 +7625,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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user