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:
turingcat
2026-07-15 11:17:45 +08:00
co-authored by Claude Fable 5
parent cf6aa1a5c3
commit 2bbdd47002
19 changed files with 380 additions and 16 deletions
@@ -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>
+3
View File
@@ -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)',
+3
View File
@@ -569,6 +569,9 @@ export default {
deletePlanConfirm: '确定要删除此套餐吗?',
originalPrice: '原价',
price: '价格',
currency: '币种标注',
currencyPlaceholder: '如 USD / NZD / CNY',
currencyHint: '仅用于价格展示的 ISO 三字母币种码,留空不展示,不影响实际扣款',
subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}',
subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})',
validityDays: '有效期(天)',
+2
View File
@@ -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,