From a6ecc202f0288462815abd6f34cbe7a4f2d2ffae Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Mon, 20 Jul 2026 18:40:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E7=94=A8=E6=88=B7=E4=BE=A7?= =?UTF-8?q?=E5=A5=97=E9=A4=90=E6=9C=89=E6=95=88=E6=9C=9F=E5=8D=95=E4=BD=8D?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=8A=E3=80=8C=E6=9C=88/=E5=91=A8?= =?UTF-8?q?=E3=80=8D=E6=98=BE=E7=A4=BA=E6=88=90=E3=80=8C=E5=A4=A9=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理端表单保存的有效期单位是复数(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 文案与单元/组件测试。 --- .../payment/SubscriptionPlanCard.vue | 8 +-- .../__tests__/SubscriptionPlanCard.spec.ts | 13 +++++ .../payment/__tests__/validity.spec.ts | 51 +++++++++++++++++++ frontend/src/components/payment/validity.ts | 32 ++++++++++++ frontend/src/i18n/locales/en/misc.ts | 1 + frontend/src/i18n/locales/zh/misc.ts | 1 + frontend/src/views/user/PaymentView.vue | 6 +-- 7 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/payment/__tests__/validity.spec.ts create mode 100644 frontend/src/components/payment/validity.ts diff --git a/frontend/src/components/payment/SubscriptionPlanCard.vue b/frontend/src/components/payment/SubscriptionPlanCard.vue index 55cf3384c..5b34056bf 100644 --- a/frontend/src/components/payment/SubscriptionPlanCard.vue +++ b/frontend/src/components/payment/SubscriptionPlanCard.vue @@ -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)) diff --git a/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts index 5e1732135..dc8f99378 100644 --- a/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts +++ b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts @@ -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(); diff --git a/frontend/src/components/payment/__tests__/validity.spec.ts b/frontend/src/components/payment/__tests__/validity.spec.ts new file mode 100644 index 000000000..e301719fb --- /dev/null +++ b/frontend/src/components/payment/__tests__/validity.spec.ts @@ -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周') + }) +}) diff --git a/frontend/src/components/payment/validity.ts b/frontend/src/components/payment/validity.ts new file mode 100644 index 000000000..0c85ddf97 --- /dev/null +++ b/frontend/src/components/payment/validity.ts @@ -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, + 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')}` +} diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts index eae9d237d..13e94e9d3 100644 --- a/frontend/src/i18n/locales/en/misc.ts +++ b/frontend/src/i18n/locales/en/misc.ts @@ -459,6 +459,7 @@ export default { models: 'Models', }, days: 'days', + weeks: 'weeks', months: 'months', years: 'years', oneMonth: '1 Month', diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts index 31247608d..04174b9fe 100644 --- a/frontend/src/i18n/locales/zh/misc.ts +++ b/frontend/src/i18n/locales/zh/misc.ts @@ -483,6 +483,7 @@ export default { models: '模型', }, days: '天', + weeks: '周', months: '个月', years: '年', oneMonth: '1 个月', diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 6d1d2fd31..7630c7c1a 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -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 {