diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index 1908520..3672c52 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -64,6 +64,7 @@ import { parseXaiBillingPayload, resolveCodexChatgptAccountId, resolveCodexPlanType, + resolveCodexSubscriptionActiveUntil, resolveGeminiCliProjectId, formatCodexResetLabel, formatQuotaResetTime, @@ -83,6 +84,7 @@ import { isXaiFile, } from '@/utils/quota'; import { normalizeAuthIndex } from '@/utils/authIndex'; +import { formatDateTimeValue } from '@/utils/format'; import type { QuotaRenderHelpers } from './QuotaCard'; import styles from '@/pages/QuotaPage.module.scss'; @@ -424,7 +426,11 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex const fetchCodexQuota = async ( file: AuthFileItem, t: TFunction -): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => { +): Promise<{ + planType: string | null; + subscriptionActiveUntil: string | number | null; + windows: CodexQuotaWindow[]; +}> => { const rawAuthIndex = file['auth_index'] ?? file.authIndex; const authIndex = normalizeAuthIndex(rawAuthIndex); if (!authIndex) { @@ -432,6 +438,7 @@ const fetchCodexQuota = async ( } const planTypeFromFile = resolveCodexPlanType(file); + const subscriptionActiveUntil = resolveCodexSubscriptionActiveUntil(file); const accountId = resolveCodexChatgptAccountId(file); const requestHeader: Record = { @@ -459,7 +466,7 @@ const fetchCodexQuota = async ( const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType); const windows = buildCodexQuotaWindows(payload, t); - return { planType: planTypeFromUsage ?? planTypeFromFile, windows }; + return { planType: planTypeFromUsage ?? planTypeFromFile, subscriptionActiveUntil, windows }; }; const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI'; @@ -760,6 +767,7 @@ const renderCodexItems = ( const { createElement: h, Fragment } = React; const windows = quota.windows ?? []; const planType = quota.planType ?? null; + const subscriptionActiveUntil = quota.subscriptionActiveUntil ?? null; const getPlanLabel = (pt?: string | null): string | null => { const normalized = normalizePlanType(pt); @@ -776,18 +784,48 @@ const renderCodexItems = ( const planLabel = getPlanLabel(planType); const isPremiumPlan = PREMIUM_CODEX_PLAN_TYPES.has(normalizePlanType(planType) ?? ''); + const expiryLabel = subscriptionActiveUntil ? formatDateTimeValue(subscriptionActiveUntil) : ''; const nodes: ReactNode[] = []; - if (planLabel) { - const valueClass = isPremiumPlan ? styleMap.premiumPlanValue : styleMap.codexPlanValue; - nodes.push( - h( - 'div', - { key: 'plan', className: styleMap.codexPlan }, - h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')), - h('span', { className: valueClass }, planLabel) - ) - ); + if (planLabel || expiryLabel) { + const planValueClass = isPremiumPlan ? styleMap.premiumPlanValue : styleMap.codexPlanValue; + const planNodes: ReactNode[] = []; + + if (planLabel) { + planNodes.push( + h( + 'span', + { key: 'plan-label', className: styleMap.codexPlanLabel }, + t('codex_quota.plan_label') + ), + h('span', { key: 'plan-value', className: planValueClass }, planLabel) + ); + } + + if (expiryLabel) { + if (planNodes.length > 0) { + planNodes.push( + h('span', { + key: 'subscription-expiry-separator', + className: styleMap.codexPlanSeparator, + }) + ); + } + planNodes.push( + h( + 'span', + { key: 'subscription-expiry-label', className: styleMap.codexPlanLabel }, + t('codex_quota.expires_label') + ), + h( + 'span', + { key: 'subscription-expiry-value', className: styleMap.codexPlanValue }, + expiryLabel + ) + ); + } + + nodes.push(h('div', { key: 'plan', className: styleMap.codexPlan }, ...planNodes)); } if (windows.length === 0) { @@ -1199,7 +1237,11 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig = { type: 'codex', i18nPrefix: 'codex_quota', @@ -1213,6 +1255,7 @@ export const CODEX_CONFIG: QuotaConfig< status: 'success', windows: data.windows, planType: data.planType, + subscriptionActiveUntil: data.subscriptionActiveUntil, }), buildErrorState: (message, status) => ({ status: 'error', diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dcc798e..5c444b7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -436,6 +436,7 @@ "additional_primary_window": "{{name}} 5-hour limit", "additional_secondary_window": "{{name}} weekly limit", "plan_label": "Plan", + "expires_label": "Expires", "plan_plus": "Plus", "plan_team": "Team", "plan_free": "Free", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d0a0cb6..052dd6a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -430,6 +430,7 @@ "additional_primary_window": "{{name}}: лимит на 5 часов", "additional_secondary_window": "{{name}}: недельный лимит", "plan_label": "Тариф", + "expires_label": "Истекает", "plan_plus": "Plus", "plan_team": "Team", "plan_free": "Free", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0af994b..5c7586d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -436,6 +436,7 @@ "additional_primary_window": "{{name}} 5 小时限额", "additional_secondary_window": "{{name}} 周限额", "plan_label": "套餐", + "expires_label": "到期时间", "plan_plus": "Plus", "plan_team": "Team", "plan_free": "Free", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 53821ed..5bfe0e3 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -436,6 +436,7 @@ "additional_primary_window": "{{name}} 5 小時限額", "additional_secondary_window": "{{name}} 週限額", "plan_label": "方案", + "expires_label": "到期時間", "plan_plus": "Plus", "plan_team": "Team", "plan_free": "Free", diff --git a/src/pages/QuotaPage.module.scss b/src/pages/QuotaPage.module.scss index fda1ad9..a6d3967 100644 --- a/src/pages/QuotaPage.module.scss +++ b/src/pages/QuotaPage.module.scss @@ -434,6 +434,7 @@ gap: 6px; font-size: 12px; color: var(--text-secondary); + flex-wrap: wrap; } .codexPlanLabel { @@ -446,6 +447,12 @@ text-transform: capitalize; } +.codexPlanSeparator { + width: 1px; + height: 12px; + background-color: var(--border-color); +} + .premiumPlanValue { position: relative; display: inline-flex; diff --git a/src/types/quota.ts b/src/types/quota.ts index 0370a86..57145b6 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -242,6 +242,7 @@ export interface CodexQuotaState { status: 'idle' | 'loading' | 'success' | 'error'; windows: CodexQuotaWindow[]; planType?: string | null; + subscriptionActiveUntil?: string | number | null; error?: string; errorStatus?: number; } diff --git a/src/utils/quota/resolvers.ts b/src/utils/quota/resolvers.ts index c3c7563..f907f3d 100644 --- a/src/utils/quota/resolvers.ts +++ b/src/utils/quota/resolvers.ts @@ -4,11 +4,24 @@ import type { AuthFileItem } from '@/types'; import { + normalizeNumberValue, normalizeStringValue, normalizePlanType, - parseIdTokenPayload + parseIdTokenPayload, } from './parsers'; +const toRecord = (value: unknown): Record | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +}; + +const resolveCodexAuthInfo = (value: unknown): Record | null => { + const payload = parseIdTokenPayload(value); + if (!payload) return null; + const nested = toRecord(payload['https://api.openai.com/auth']); + return nested ?? payload; +}; + export function extractCodexChatgptAccountId(value: unknown): string | null { const payload = parseIdTokenPayload(value); if (!payload) return null; @@ -67,7 +80,7 @@ export function resolveCodexPlanType(file: AuthFileItem): string | null { metadataIdToken?.planType, attributes?.plan_type, attributes?.planType, - attributes?.id_token + attributes?.id_token, ]; for (const candidate of candidates) { @@ -78,6 +91,61 @@ export function resolveCodexPlanType(file: AuthFileItem): string | null { return null; } +const normalizeDateLikeValue = (value: unknown): string | number | null => { + const numberValue = normalizeNumberValue(value); + if (numberValue === 0) return null; + if (numberValue !== null) return numberValue; + + const stringValue = normalizeStringValue(value); + if (!stringValue || stringValue === '0') return null; + return stringValue; +}; + +export function resolveCodexSubscriptionActiveUntil(file: AuthFileItem): string | number | null { + const metadata = toRecord(file.metadata); + const attributes = toRecord(file.attributes); + const idToken = resolveCodexAuthInfo(file.id_token); + const metadataIdToken = resolveCodexAuthInfo(metadata?.id_token); + const attributesIdToken = resolveCodexAuthInfo(attributes?.id_token); + const subscription = toRecord(file.subscription); + const metadataSubscription = toRecord(metadata?.subscription); + const attributesSubscription = toRecord(attributes?.subscription); + + const candidates = [ + file.chatgpt_subscription_active_until, + file.chatgptSubscriptionActiveUntil, + file.subscription_active_until, + file.subscriptionActiveUntil, + subscription?.active_until, + subscription?.activeUntil, + idToken?.chatgpt_subscription_active_until, + idToken?.chatgptSubscriptionActiveUntil, + metadata?.chatgpt_subscription_active_until, + metadata?.chatgptSubscriptionActiveUntil, + metadata?.subscription_active_until, + metadata?.subscriptionActiveUntil, + metadataSubscription?.active_until, + metadataSubscription?.activeUntil, + metadataIdToken?.chatgpt_subscription_active_until, + metadataIdToken?.chatgptSubscriptionActiveUntil, + attributes?.chatgpt_subscription_active_until, + attributes?.chatgptSubscriptionActiveUntil, + attributes?.subscription_active_until, + attributes?.subscriptionActiveUntil, + attributesSubscription?.active_until, + attributesSubscription?.activeUntil, + attributesIdToken?.chatgpt_subscription_active_until, + attributesIdToken?.chatgptSubscriptionActiveUntil, + ]; + + for (const candidate of candidates) { + const value = normalizeDateLikeValue(candidate); + if (value !== null) return value; + } + + return null; +} + export function extractGeminiCliProjectId(value: unknown): string | null { if (typeof value !== 'string') return null; const matches = Array.from(value.matchAll(/\(([^()]+)\)/g)); @@ -96,12 +164,7 @@ export function resolveGeminiCliProjectId(file: AuthFileItem): string | null { ? (file.attributes as Record) : null; - const candidates = [ - file.account, - file['account'], - metadata?.account, - attributes?.account - ]; + const candidates = [file.account, file['account'], metadata?.account, attributes?.account]; for (const candidate of candidates) { const projectId = extractGeminiCliProjectId(candidate);