From 8f2cba0c20bbc2c58c21417e0cd7a60dafe21509 Mon Sep 17 00:00:00 2001 From: Zero Clover Date: Fri, 11 Sep 2026 03:25:04 +0800 Subject: [PATCH] fix(openai): map new ChatGPT plan tiers to friendly labels prolite -> Pro 5x (violet, same as Pro), pro/chatgptpro -> Pro 20x, self_serve_business_prolite -> Business Premium (indigo, same as Team), team -> Business Standard. The tier naming stays OpenAI-only so Antigravity Pro and Grok pro keep their own labels; planTypeDisplayLabel now shares openAIPlanTypeLabel with PlatformTypeBadge instead of duplicating the mapping, and the manual plan_type dropdown gains the Pro 5x / Business Premium presets. --- .../components/account/EditAccountModal.vue | 6 +- .../__tests__/credentialsBuilder.spec.ts | 66 ++++++++++--- .../components/account/credentialsBuilder.ts | 29 +++--- .../components/common/PlatformTypeBadge.vue | 19 +++- .../PlatformTypeBadge.openaiPlans.spec.ts | 93 +++++++++++++++++++ frontend/src/utils/planType.ts | 43 +++++++++ 6 files changed, 220 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts create mode 100644 frontend/src/utils/planType.ts diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index a6faed9d9b..a4bf04e74d 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3419,7 +3419,8 @@ const openaiPassthroughEnabled = ref(false) // OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 const openaiFlattenNamespacesEnabled = ref(false) const openAILongContextBillingEnabled = ref(false) -// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别 +// OpenAI 订阅档位(Plus / Pro 20x / Pro 5x / Business Standard / Business Premium / Free)手动覆盖值, +// 存于 credentials.plan_type;'' 表示清空/自动识别 const editPlanType = ref('') const openAICompactMode = ref('auto') const openAIResponsesMode = ref('auto') @@ -5210,7 +5211,8 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } - // OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号: + // OpenAI: 手动覆盖订阅档位 plan_type(Plus / Pro 20x / Pro 5x / Business Standard / Business Premium / Free)。 + // 仅 OAuth 非影子账号: // 影子账号凭据由母账号管理(且后端会 sanitize),setup-token 无订阅调度语义。 if (props.account.platform === 'openai' && props.account.type === 'oauth' && !isSparkShadow.value) { const currentCredentials = (updatePayload.credentials as Record) || diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index 9af2b9049f..8912bc57a8 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -396,11 +396,18 @@ describe('plan_type helpers', () => { describe('planTypeDisplayLabel', () => { it('maps canonical + alias values to friendly labels', () => { expect(planTypeDisplayLabel('plus')).toBe('Plus') - expect(planTypeDisplayLabel('pro')).toBe('Pro') - expect(planTypeDisplayLabel('chatgptpro')).toBe('Pro') + expect(planTypeDisplayLabel('pro')).toBe('Pro 20x') + expect(planTypeDisplayLabel('chatgptpro')).toBe('Pro 20x') + expect(planTypeDisplayLabel('prolite')).toBe('Pro 5x') expect(planTypeDisplayLabel('free')).toBe('Free') - expect(planTypeDisplayLabel('team')).toBe('Team') - expect(planTypeDisplayLabel('CHATGPTPRO')).toBe('Pro') + expect(planTypeDisplayLabel('team')).toBe('Business Standard') + expect(planTypeDisplayLabel('self_serve_business_prolite')).toBe('Business Premium') + }) + it('normalizes case, separators and surrounding blanks', () => { + expect(planTypeDisplayLabel('CHATGPTPRO')).toBe('Pro 20x') + expect(planTypeDisplayLabel('PROLITE')).toBe('Pro 5x') + expect(planTypeDisplayLabel(' Pro Lite ')).toBe('Pro 5x') + expect(planTypeDisplayLabel('self-serve-business-pro-lite')).toBe('Business Premium') }) it('returns unknown values verbatim', () => { expect(planTypeDisplayLabel('self_serve_business')).toBe('self_serve_business') @@ -426,22 +433,39 @@ describe('plan_type helpers', () => { expect(buildPlanTypeOptions('', clear)).toEqual([ { value: '', label: clear }, { value: 'plus', label: 'Plus' }, - { value: 'pro', label: 'Pro' }, + { value: 'pro', label: 'Pro 20x' }, + { value: 'prolite', label: 'Pro 5x' }, + { value: 'self_serve_business_prolite', label: 'Business Premium' }, { value: 'free', label: 'Free' } ]) }) - it('keeps canonical chatgptpro under a single friendly "Pro" option (no duplicate)', () => { + it('keeps canonical chatgptpro under a single friendly "Pro 20x" option (no duplicate)', () => { const opts = buildPlanTypeOptions('chatgptpro', clear) - const pros = opts.filter(o => o.label === 'Pro') + const pros = opts.filter(o => o.label === 'Pro 20x') expect(pros).toHaveLength(1) expect(pros[0].value).toBe('chatgptpro') - expect(opts.map(o => o.value)).toEqual(['', 'plus', 'chatgptpro', 'free']) + expect(opts.map(o => o.value)).toEqual([ + '', + 'plus', + 'chatgptpro', + 'prolite', + 'self_serve_business_prolite', + 'free' + ]) }) it('appends an unknown-but-labeled value (team) as its own option', () => { const opts = buildPlanTypeOptions('team', clear) - expect(opts.find(o => o.value === 'team')).toEqual({ value: 'team', label: 'Team' }) + expect(opts.find(o => o.value === 'team')).toEqual({ value: 'team', label: 'Business Standard' }) // presets untouched - expect(opts.map(o => o.value)).toEqual(['', 'plus', 'pro', 'free', 'team']) + expect(opts.map(o => o.value)).toEqual([ + '', + 'plus', + 'pro', + 'prolite', + 'self_serve_business_prolite', + 'free', + 'team' + ]) }) it('appends a fully custom value with a raw label', () => { const opts = buildPlanTypeOptions('weird_x', clear) @@ -450,7 +474,27 @@ describe('plan_type helpers', () => { it('does not duplicate an exact preset value', () => { const opts = buildPlanTypeOptions('pro', clear) expect(opts.filter(o => o.value === 'pro')).toHaveLength(1) - expect(opts.map(o => o.value)).toEqual(['', 'plus', 'pro', 'free']) + expect(opts.map(o => o.value)).toEqual([ + '', + 'plus', + 'pro', + 'prolite', + 'self_serve_business_prolite', + 'free' + ]) + }) + it('does not duplicate a preset value that is the current one', () => { + for (const preset of ['prolite', 'self_serve_business_prolite']) { + const opts = buildPlanTypeOptions(preset, clear) + expect(opts.filter(o => o.value === preset)).toHaveLength(1) + } + }) + it('keeps a separator-free variant of a preset as its own value', () => { + const opts = buildPlanTypeOptions('selfservebusinessprolite', clear) + expect(opts.find(o => o.value === 'selfservebusinessprolite')).toEqual({ + value: 'selfservebusinessprolite', + label: 'Business Premium' + }) }) }) diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index a41a7b5873..a6bf8aacec 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -1,3 +1,5 @@ +import { openAIPlanTypeLabel } from '@/utils/planType' + export function applyInterceptWarmup( credentials: Record, enabled: boolean, @@ -406,23 +408,12 @@ export interface PlanTypeOption { } /** - * plan_type 值的友好显示标签,镜像 PlatformTypeBadge 的映射 - * (canonical 值 chatgptpro 显示为 Pro,team 显示为 Team)。未知值原样返回。 + * plan_type 值的友好显示标签(ChatGPT 档位命名)。 + * 与 PlatformTypeBadge 共用 openAIPlanTypeLabel,避免两处映射漂移; + * canonical 值 chatgptpro 显示为 Pro 20x,team 显示为 Business Standard。未知值原样返回。 */ export function planTypeDisplayLabel(value: string): string { - switch (value.trim().toLowerCase()) { - case 'plus': - return 'Plus' - case 'pro': - case 'chatgptpro': - return 'Pro' - case 'free': - return 'Free' - case 'team': - return 'Team' - default: - return value - } + return openAIPlanTypeLabel(value) || value } /** @@ -435,8 +426,8 @@ export function readPlanType(credentials: Record | undefined | } /** - * 构建 plan_type 下拉选项:清空 + Plus/Pro/Free 预设。 - * 若当前值是某预设的别名(如 chatgptpro↔Pro),用当前的 canonical 值占据该 + * 构建 plan_type 下拉选项:清空 + Plus/Pro 20x/Pro 5x/Business Premium/Free 预设。 + * 若当前值是某预设的别名(如 chatgptpro↔Pro 20x),用当前的 canonical 值占据该 * 标签位(保留 canonical,显示友好标签,避免重复项);若是完全预设外的值 * (如 team 或异常值),追加为一项,避免编辑时下拉丢失原值。 */ @@ -445,7 +436,9 @@ export function buildPlanTypeOptions(current: string, clearLabel: string): PlanT const curLabel = cur ? planTypeDisplayLabel(cur) : '' const presets: PlanTypeOption[] = [ { value: 'plus', label: 'Plus' }, - { value: 'pro', label: 'Pro' }, + { value: 'pro', label: 'Pro 20x' }, + { value: 'prolite', label: 'Pro 5x' }, + { value: 'self_serve_business_prolite', label: 'Business Premium' }, { value: 'free', label: 'Free' } ] const opts: PlanTypeOption[] = [{ value: '', label: clearLabel }] diff --git a/frontend/src/components/common/PlatformTypeBadge.vue b/frontend/src/components/common/PlatformTypeBadge.vue index 867ceed4cb..fc295f693a 100644 --- a/frontend/src/components/common/PlatformTypeBadge.vue +++ b/frontend/src/components/common/PlatformTypeBadge.vue @@ -69,6 +69,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { AccountPlatform, AccountType } from '@/types' import { platformLabel as sharedPlatformLabel } from '@/utils/platformColors' +import { normalizePlanType, openAIPlanTypeLabel } from '@/utils/planType' import GrokFreeIcon from './GrokFreeIcon.vue' import PlatformIcon from './PlatformIcon.vue' import Icon from '@/components/icons/Icon.vue' @@ -113,12 +114,16 @@ const typeLabel = computed(() => { } }) -const normalizedPlanType = computed(() => - (props.planType || '').trim().toLowerCase().replace(/[\s_-]+/g, '') -) +const normalizedPlanType = computed(() => normalizePlanType(props.planType)) const planLabel = computed(() => { if (!normalizedPlanType.value) return '' + // ChatGPT 档位命名(Pro 5x / Pro 20x、Business Standard / Business Premium)只适用于 + // OpenAI:Antigravity 与 Grok 各自的 pro/team 沿用下面的通用标签。 + if (props.platform === 'openai') { + const label = openAIPlanTypeLabel(props.planType) + if (label) return label + } switch (normalizedPlanType.value) { case 'plus': return 'Plus' @@ -254,10 +259,14 @@ const planBadgeClass = computed(() => { if (normalizedPlanType.value === 'plus') { return 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300' } - if (normalizedPlanType.value === 'team') { + if (normalizedPlanType.value === 'team' || normalizedPlanType.value === 'selfservebusinessprolite') { return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300' } - if (normalizedPlanType.value === 'pro' || normalizedPlanType.value === 'chatgptpro') { + if ( + normalizedPlanType.value === 'pro' || + normalizedPlanType.value === 'chatgptpro' || + normalizedPlanType.value === 'prolite' + ) { return 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300' } return typeClass.value diff --git a/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts b/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts new file mode 100644 index 0000000000..e8ce24ecd5 --- /dev/null +++ b/frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts @@ -0,0 +1,93 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import type { AccountPlatform } from '@/types' +import PlatformTypeBadge from '../PlatformTypeBadge.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + } +}) + +function mountPlan(platform: AccountPlatform, planType: string) { + return mount(PlatformTypeBadge, { + props: { platform, type: 'oauth', planType }, + }) +} + +describe('PlatformTypeBadge ChatGPT plan tiers', () => { + it('labels pro / chatgptpro as Pro 20x with the Pro color', () => { + for (const planType of ['pro', 'chatgptpro', 'PRO']) { + const wrapper = mountPlan('openai', planType) + + expect(wrapper.text()).toContain('Pro 20x') + expect(wrapper.html()).toContain('bg-violet-100') + } + }) + + it('labels prolite as Pro 5x sharing the Pro color', () => { + for (const planType of ['prolite', 'PROLITE', 'pro_lite']) { + const wrapper = mountPlan('openai', planType) + + expect(wrapper.text()).toContain('Pro 5x') + expect(wrapper.html()).toContain('bg-violet-100') + expect(wrapper.text()).not.toContain('Pro 20x') + } + }) + + it('labels team as Business Standard with the Team color', () => { + const wrapper = mountPlan('openai', 'team') + + expect(wrapper.text()).toContain('Business Standard') + expect(wrapper.html()).toContain('bg-indigo-100') + }) + + it('labels self_serve_business_prolite as Business Premium sharing the Team color', () => { + for (const planType of ['self_serve_business_prolite', 'selfservebusinessprolite']) { + const wrapper = mountPlan('openai', planType) + + expect(wrapper.text()).toContain('Business Premium') + expect(wrapper.html()).toContain('bg-indigo-100') + expect(wrapper.text()).not.toContain('self_serve_business_prolite') + } + }) + + it('keeps plus, free and abnormal labels unchanged', () => { + const plus = mountPlan('openai', 'plus') + expect(plus.text()).toContain('Plus') + expect(plus.html()).toContain('bg-sky-100') + + const free = mountPlan('openai', 'free') + expect(free.text()).toContain('Free') + expect(free.html()).toContain('bg-gray-100') + + const abnormal = mountPlan('openai', 'abnormal') + expect(abnormal.text()).toContain('admin.accounts.subscriptionAbnormal') + expect(abnormal.html()).toContain('bg-red-100') + }) + + it('falls back to the raw value for unknown plans', () => { + const wrapper = mountPlan('openai', 'enterprise') + + expect(wrapper.text()).toContain('enterprise') + expect(wrapper.html()).not.toContain('bg-violet-100') + expect(wrapper.html()).not.toContain('bg-indigo-100') + }) + + it('does not apply the ChatGPT tier naming to other platforms', () => { + // Antigravity 的 Pro 与 Grok 的 pro 是各自产品线的档位,不能显示成 Pro 20x。 + for (const platform of ['antigravity', 'grok'] as AccountPlatform[]) { + const wrapper = mountPlan(platform, 'pro') + + expect(wrapper.text()).toContain('Pro') + expect(wrapper.text()).not.toContain('Pro 20x') + } + + const antigravityTeam = mountPlan('antigravity', 'team') + expect(antigravityTeam.text()).toContain('Team') + expect(antigravityTeam.text()).not.toContain('Business Standard') + }) +}) diff --git a/frontend/src/utils/planType.ts b/frontend/src/utils/planType.ts new file mode 100644 index 0000000000..c1514763aa --- /dev/null +++ b/frontend/src/utils/planType.ts @@ -0,0 +1,43 @@ +/** + * ChatGPT(OpenAI)订阅档位 plan_type 的解析与展示映射。 + * + * plan_type 由上游原样透传,同一档位会出现 `chatgpt_pro`、`self_serve_business_prolite` + * 这类下划线/别名写法,因此匹配前一律先归一化。 + * + * 这里的档位命名只对 OpenAI 平台成立:Antigravity 的 `Pro`、Grok 的 `pro` 是各自产品线的 + * 档位,套用 ChatGPT 的倍率命名(Pro 5x / Pro 20x)会显示错误。 + */ + +/** + * plan_type 归一化:去首尾空白、转小写,并去掉空格/下划线/连字符。 + * 例:`self_serve_business_prolite` → `selfservebusinessprolite`。 + */ +export function normalizePlanType(value?: string | null): string { + return (value || '').trim().toLowerCase().replace(/[\s_-]+/g, '') +} + +/** + * ChatGPT 档位 → 展示标签;未知档位返回空串,由调用方决定是否回退为原始值。 + * + * Pro 的倍率命名:`pro`/`chatgptpro` 为 Pro 20x,`prolite` 为 Pro 5x; + * Team/Business:`team` 为 Business Standard,`self_serve_business_prolite` 为 Business Premium。 + */ +export function openAIPlanTypeLabel(value?: string | null): string { + switch (normalizePlanType(value)) { + case 'plus': + return 'Plus' + case 'chatgptpro': + case 'pro': + return 'Pro 20x' + case 'prolite': + return 'Pro 5x' + case 'selfservebusinessprolite': + return 'Business Premium' + case 'team': + return 'Business Standard' + case 'free': + return 'Free' + default: + return '' + } +}