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
@@ -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,