mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 00:21:34 +00:00
feat(payment): 订阅套餐支持币种标注,区分 $ 符号对应币种
后台配置与用户端展示的套餐价格统一使用 $ 符号,而实际扣款币种随 支付渠道配置漂移(如 Stripe 配置 NZD 时按新西兰元扣款),用户易 误认为美元。本次为套餐新增可选的展示性币种标注: - SubscriptionPlan 新增 currency 字段(ISO 4217 三字母码,默认空) - 空值不展示任何标注,存量套餐行为完全不变 - 非空值复用 payment.NormalizePaymentCurrency 校验并规范化为大写 - 后台套餐编辑弹窗新增可选币种输入,列表价格列展示币种小字 - 用户端套餐卡片价格与划线原价旁展示币种小字 - 纯展示性质,不影响任何支付/扣款逻辑 Closes #4315 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
cf6aa1a5c3
commit
2bbdd47002
@@ -28,10 +28,11 @@
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-xs text-gray-400 dark:text-dark-500">$</span>
|
||||
<span :class="['text-2xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
|
||||
<span v-if="plan.currency" class="text-xs font-medium text-gray-400 dark:text-dark-500">{{ plan.currency }}</span>
|
||||
</div>
|
||||
<span class="text-[11px] text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
|
||||
<div v-if="plan.original_price" class="mt-0.5 flex items-center justify-end gap-1.5">
|
||||
<span class="text-xs text-gray-400 line-through dark:text-dark-500">${{ plan.original_price }}</span>
|
||||
<span class="text-xs text-gray-400 line-through dark:text-dark-500">${{ plan.original_price }}<template v-if="plan.currency"> {{ plan.currency }}</template></span>
|
||||
<span :class="['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]">{{ discountText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -545,6 +545,9 @@ export default {
|
||||
deletePlanConfirm: 'Are you sure you want to delete this plan?',
|
||||
originalPrice: 'Original Price',
|
||||
price: 'Price',
|
||||
currency: 'Currency Label',
|
||||
currencyPlaceholder: 'e.g. USD / NZD / CNY',
|
||||
currencyHint: 'Display-only 3-letter ISO currency code shown next to the price; leave empty to hide, does not affect billing',
|
||||
subscriptionCnyPayPreview: 'CNY channel charge preview: {amount}',
|
||||
subscriptionCnyPayPreviewWithFee: '({feeRate}% fee included: {total})',
|
||||
validityDays: 'Validity (days)',
|
||||
|
||||
@@ -569,6 +569,9 @@ export default {
|
||||
deletePlanConfirm: '确定要删除此套餐吗?',
|
||||
originalPrice: '原价',
|
||||
price: '价格',
|
||||
currency: '币种标注',
|
||||
currencyPlaceholder: '如 USD / NZD / CNY',
|
||||
currencyHint: '仅用于价格展示的 ISO 三字母币种码,留空不展示,不影响实际扣款',
|
||||
subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}',
|
||||
subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})',
|
||||
validityDays: '有效期(天)',
|
||||
|
||||
@@ -124,6 +124,8 @@ export interface SubscriptionPlan {
|
||||
description: string
|
||||
price: number
|
||||
original_price?: number
|
||||
/** Display-only ISO 4217 currency label (e.g. "NZD"); empty means no label */
|
||||
currency?: string
|
||||
validity_days: number
|
||||
validity_unit: string
|
||||
/** Stored as JSON string in backend; API layer should parse before use */
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<template #cell-price="{ value, row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ (value ?? 0).toFixed(2) }}</span>
|
||||
<span v-if="row.currency" class="ml-1 text-xs text-gray-400">{{ row.currency }}</span>
|
||||
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.currency') }}</label>
|
||||
<input v-model="planForm.currency" type="text" maxlength="3" class="input uppercase" :placeholder="t('payment.admin.currencyPlaceholder')" />
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.currencyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.features') }}</label>
|
||||
@@ -117,7 +122,7 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const saving = ref(false)
|
||||
const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
|
||||
const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, currency: '', validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
|
||||
const planFeaturesText = ref('')
|
||||
|
||||
const validityUnitOptions = computed(() => [
|
||||
@@ -170,10 +175,10 @@ const subscriptionCnyPreview = computed(() => {
|
||||
watch(() => props.show, (visible) => {
|
||||
if (!visible) return
|
||||
if (props.plan) {
|
||||
Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', sort_order: props.plan.sort_order || 0, for_sale: props.plan.for_sale })
|
||||
Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, currency: props.plan.currency || '', validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', sort_order: props.plan.sort_order || 0, for_sale: props.plan.for_sale })
|
||||
planFeaturesText.value = (props.plan.features || []).join('\n')
|
||||
} else {
|
||||
Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
|
||||
Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, currency: '', validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
|
||||
planFeaturesText.value = ''
|
||||
}
|
||||
})
|
||||
@@ -187,6 +192,7 @@ function buildPlanPayload() {
|
||||
description: planForm.description,
|
||||
price: planForm.price,
|
||||
original_price: planForm.original_price || 0,
|
||||
currency: planForm.currency.trim().toUpperCase(),
|
||||
validity_days: planForm.validity_days,
|
||||
validity_unit: planForm.validity_unit,
|
||||
sort_order: planForm.sort_order,
|
||||
|
||||
Reference in New Issue
Block a user