mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 17:08:33 +08:00
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:
@@ -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')}`
|
||||
}
|
||||
@@ -459,6 +459,7 @@ export default {
|
||||
models: 'Models',
|
||||
},
|
||||
days: 'days',
|
||||
weeks: 'weeks',
|
||||
months: 'months',
|
||||
years: 'years',
|
||||
oneMonth: '1 Month',
|
||||
|
||||
@@ -483,6 +483,7 @@ export default {
|
||||
models: '模型',
|
||||
},
|
||||
days: '天',
|
||||
weeks: '周',
|
||||
months: '个月',
|
||||
years: '年',
|
||||
oneMonth: '1 个月',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user