feat(quota): add subscription expiry handling and improve UI elements

This commit is contained in:
LTbinglingfeng
2026-06-12 17:03:19 +08:00
Unverified
parent 4b3daa0010
commit 67c4041939
8 changed files with 139 additions and 21 deletions
+56 -13
View File
@@ -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<string, string> = {
@@ -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<AntigravityQuotaState, AntigravityQ
export const CODEX_CONFIG: QuotaConfig<
CodexQuotaState,
{ planType: string | null; windows: CodexQuotaWindow[] }
{
planType: string | null;
subscriptionActiveUntil: string | number | null;
windows: CodexQuotaWindow[];
}
> = {
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',
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+7
View File
@@ -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;
+1
View File
@@ -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;
}
+71 -8
View File
@@ -4,11 +4,24 @@
import type { AuthFileItem } from '@/types';
import {
normalizeNumberValue,
normalizeStringValue,
normalizePlanType,
parseIdTokenPayload
parseIdTokenPayload,
} from './parsers';
const toRecord = (value: unknown): Record<string, unknown> | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
};
const resolveCodexAuthInfo = (value: unknown): Record<string, unknown> | 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<string, unknown>)
: 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);