fix(frontend): 用户侧套餐有效期单位不再把「月/周」显示成「天」

管理端表单保存的有效期单位是复数(days/weeks/months),数据库默认值
与历史数据是单数(day)。用户侧套餐卡片与订单确认面板此前只匹配单数
'month'(以及计费根本不支持的 'year'),管理端存的 'months' 永远落进
默认分支:「1 个月」的套餐被显示成「1天」(#4607),'weeks' 则会把
周数错标成天数。

后端计费 psComputeValidityDays 对 week/weeks、month/months 单复数均
按 ×7/×30 天换算,实扣是对的——这是纯显示缺陷,但直接造成「显示 1 天、
实付 1 个月」的定价误解。

修复:抽出与计费语义一一对应的 planValiditySuffix 辅助函数(单复数
归一化;month→「月/N个月」、week→「N周」、其余含未知单位一律按天,
与后端兜底一致),SubscriptionPlanCard 与 PaymentView 两处共用;
补充 zh/en 的 weeks 文案与单元/组件测试。
This commit is contained in:
superman2003
2026-07-20 18:40:42 +08:00
parent e625ce3b3b
commit a6ecc202f0
7 changed files with 102 additions and 10 deletions
@@ -106,6 +106,7 @@ import type { SubscriptionPlan } from '@/types/payment'
import type { UserSubscription } from '@/types'
import { useAppStore } from '@/stores/app'
import { hasPeakRate as groupHasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
import { planValiditySuffix } from './validity'
import { currencySymbol } from '@/components/payment/currency'
import {
platformAccentBarClass,
@@ -170,10 +171,5 @@ const modelScopeLabels = computed(() => {
return scopes.map(s => MODEL_SCOPE_LABELS[s] || s)
})
const validitySuffix = computed(() => {
const u = props.plan.validity_unit || 'day'
if (u === 'month') return t('payment.perMonth')
if (u === 'year') return t('payment.perYear')
return `${props.plan.validity_days}${t('payment.days')}`
})
const validitySuffix = computed(() => planValiditySuffix(props.plan, t))
</script>
@@ -14,6 +14,9 @@ const i18n = createI18n({
en: {
payment: {
days: "days",
weeks: "weeks",
months: "months",
perMonth: "month",
models: "Models",
planCard: {
quota: "Quota",
@@ -65,6 +68,16 @@ describe("SubscriptionPlanCard", () => {
expect(text).toContain("Imagen");
});
// #4607:管理端保存的单位是复数(months/weeks),此前用户侧只匹配单数
// 'month',「1 个月」的套餐卡片被显示成「1天」。测试环境的 vue-i18n 为
// runtime-only 构建,t() 原样返回 key,故按 key 断言单位分支。
it("renders plural admin-form validity units instead of mislabeled days (#4607)", () => {
expect(mountPlanCard("openai", { validity_days: 1, validity_unit: "months" }).text()).toContain("/ payment.perMonth");
expect(mountPlanCard("openai", { validity_days: 3, validity_unit: "months" }).text()).toContain("/ 3payment.months");
expect(mountPlanCard("openai", { validity_days: 2, validity_unit: "weeks" }).text()).toContain("/ 2payment.weeks");
expect(mountPlanCard("openai", { validity_days: 30, validity_unit: "day" }).text()).toContain("/ 30payment.days");
});
it("uses the configured currency symbol while preserving USD for legacy plans", () => {
const cnyPlan = mountPlanCard("openai", { currency: "CNY", original_price: 20 }).text();
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { planValiditySuffix } from '../validity'
const t = (key: string): string =>
({
'payment.perMonth': '月',
'payment.days': '天',
'payment.weeks': '周',
'payment.months': '个月',
})[key] ?? key
const suffix = (validity_days: number, validity_unit: string) =>
planValiditySuffix({ validity_days, validity_unit }, t)
describe('planValiditySuffix', () => {
// #4607:管理端表单保存的是复数 'months',此前用户侧只匹配单数 'month',
// 「1 个月」的套餐被显示成「1天」。
it('renders admin-form plural months correctly', () => {
expect(suffix(1, 'months')).toBe('月')
expect(suffix(3, 'months')).toBe('3个月')
})
it('renders singular month the same way', () => {
expect(suffix(1, 'month')).toBe('月')
expect(suffix(6, 'month')).toBe('6个月')
})
// 计费侧 weeks 按 ×7 天换算;显示必须是周数而非天数。
it('renders weeks as weeks instead of mislabeled days', () => {
expect(suffix(2, 'weeks')).toBe('2周')
expect(suffix(1, 'week')).toBe('1周')
})
it('renders day-based and legacy units as days', () => {
expect(suffix(30, 'days')).toBe('30天')
expect(suffix(30, 'day')).toBe('30天') // 数据库默认值
expect(suffix(30, '')).toBe('30天')
})
// 后端 psComputeValidityDays 对未知单位一律按天计费,显示保持一致,
// 避免出现「显示 1 年、实际 1 天」这类误导。
it('falls back to days for units billing does not honor', () => {
expect(suffix(1, 'year')).toBe('1天')
expect(suffix(365, 'unknown')).toBe('365天')
})
it('normalizes casing and whitespace', () => {
expect(suffix(1, ' Months ')).toBe('月')
expect(suffix(2, 'WEEKS')).toBe('2周')
})
})
@@ -0,0 +1,32 @@
import type { SubscriptionPlan } from '@/types/payment'
type TranslateFn = (key: string) => string
/**
* 用户侧套餐有效期后缀("$9.9 / 月"、"$9.9 / 30天")。
*
* 管理端表单保存的单位是复数(days/weeks/months),而数据库默认值与部分
* 历史数据是单数(day)。后端计费 psComputeValidityDays 对 week/weeks、
* month/months 分别按 ×7、×30 天换算,其余一律按天。此前用户侧只匹配
* 单数 'month',管理端存的 'months' 永远落进默认分支,「1 个月」的套餐
* 被显示成「1天」(#4607);'weeks' 则会显示成周数的「N天」。
*
* 这里把单位归一化后与计费语义一一对应,保证用户看到的周期与实际
* 生效周期一致。
*/
export function planValiditySuffix(
plan: Pick<SubscriptionPlan, 'validity_days' | 'validity_unit'>,
t: TranslateFn,
): string {
const unit = String(plan.validity_unit || 'day').trim().toLowerCase()
const base = unit.endsWith('s') ? unit.slice(0, -1) : unit
const days = plan.validity_days
if (base === 'month') {
return days === 1 ? t('payment.perMonth') : `${days}${t('payment.months')}`
}
if (base === 'week') {
return `${days}${t('payment.weeks')}`
}
// 其余单位(含数据库默认的 day 与未知值)后端一律按天计费,展示保持一致。
return `${days}${t('payment.days')}`
}
+1
View File
@@ -459,6 +459,7 @@ export default {
models: 'Models',
},
days: 'days',
weeks: 'weeks',
months: 'months',
years: 'years',
oneMonth: '1 Month',
+1
View File
@@ -483,6 +483,7 @@ export default {
models: '模型',
},
days: '天',
weeks: '周',
months: '个月',
years: '年',
oneMonth: '1 个月',
+2 -4
View File
@@ -284,6 +284,7 @@ import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import Icon from '@/components/icons/Icon.vue'
import { DEFAULT_PAYMENT_CURRENCY, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { planValiditySuffix as validitySuffixOf } from '@/components/payment/validity'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
@@ -718,10 +719,7 @@ const renewalPlans = computed(() => {
const planValiditySuffix = computed(() => {
if (!selectedPlan.value) return ''
const u = selectedPlan.value.validity_unit || 'day'
if (u === 'month') return t('payment.perMonth')
if (u === 'year') return t('payment.perYear')
return `${selectedPlan.value.validity_days}${t('payment.days')}`
return validitySuffixOf(selectedPlan.value, t)
})
function planHasPeakRate(plan: SubscriptionPlan): boolean {