From a48e06a28c9665944df1a773a4dfb0b827b1254d Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Mon, 29 Dec 2025 23:13:55 +0800 Subject: [PATCH] fix(auth-files): use account id for codex quota and show remaining --- src/i18n/locales/en.json | 21 + src/i18n/locales/zh-CN.json | 21 + src/pages/AuthFilesPage.module.scss | 70 ++++ src/pages/AuthFilesPage.tsx | 609 +++++++++++++++++++++++++++- 4 files changed, 714 insertions(+), 7 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dc0ce38..b741e1a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -369,6 +369,27 @@ "refresh_button": "Refresh Quota", "fetch_all": "Fetch All" }, + "codex_quota": { + "title": "Codex Quota", + "empty_title": "No Codex Auth Files", + "empty_desc": "Upload a Codex credential to view quota.", + "idle": "Not loaded. Click Refresh Button.", + "loading": "Loading quota...", + "load_failed": "Failed to load quota: {{message}}", + "missing_auth_index": "Auth file missing auth_index", + "missing_account_id": "Codex credential missing ChatGPT account ID", + "empty_windows": "No quota data available", + "no_access": "This credential has no Codex access (plan: free).", + "refresh_button": "Refresh Quota", + "fetch_all": "Fetch All", + "primary_window": "5-hour limit", + "secondary_window": "Weekly limit", + "code_review_window": "Code review limit", + "plan_label": "Plan", + "plan_plus": "Plus", + "plan_team": "Team", + "plan_free": "Free" + }, "vertex_import": { "title": "Vertex JSON Login", "description": "Upload a Google service account JSON to store it as auth-dir/vertex-.json using the same rules as the CLI vertex-import helper.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 27c0974..aedfa07 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -369,6 +369,27 @@ "refresh_button": "刷新额度", "fetch_all": "获取全部" }, + "codex_quota": { + "title": "Codex 额度", + "empty_title": "暂无 Codex 认证", + "empty_desc": "上传 Codex 认证文件后即可查看额度。", + "idle": "尚未加载额度,请点击刷新按钮。", + "loading": "正在加载额度...", + "load_failed": "额度获取失败:{{message}}", + "missing_auth_index": "认证文件缺少 auth_index", + "missing_account_id": "Codex 凭证缺少 ChatGPT 账号 ID", + "empty_windows": "暂无额度数据", + "no_access": "该凭证已无 Codex 访问权限(free)。", + "refresh_button": "刷新额度", + "fetch_all": "获取全部", + "primary_window": "5 小时限额", + "secondary_window": "周限额", + "code_review_window": "代码审查限额", + "plan_label": "套餐", + "plan_plus": "Plus", + "plan_team": "Team", + "plan_free": "Free" + }, "vertex_import": { "title": "Vertex JSON 登录", "description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-.json。", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index b4afe66..7b5a5d6 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -176,6 +176,20 @@ } } +.codexGrid { + display: grid; + gap: $spacing-md; + grid-template-columns: repeat(3, minmax(0, 1fr)); + + @include tablet { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @include mobile { + grid-template-columns: 1fr; + } +} + .antigravityControls { display: flex; gap: $spacing-md; @@ -197,6 +211,27 @@ } } +.codexControls { + display: flex; + gap: $spacing-md; + flex-wrap: wrap; + align-items: flex-end; + margin-bottom: $spacing-md; +} + +.codexControl { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 500; + white-space: nowrap; + } +} + .antigravityCard { background-image: linear-gradient( 180deg, @@ -205,6 +240,14 @@ ); } +.codexCard { + background-image: linear-gradient( + 180deg, + rgba(255, 243, 224, 0.18), + rgba(255, 243, 224, 0) + ); +} + .quotaSection { display: flex; flex-direction: column; @@ -311,6 +354,33 @@ padding: $spacing-xs $spacing-sm; } +.quotaWarning { + font-size: 12px; + color: var(--warning-color, #f59e0b); + background-color: rgba(245, 158, 11, 0.12); + border: 1px solid var(--warning-color, #f59e0b); + border-radius: $radius-sm; + padding: $spacing-xs $spacing-sm; +} + +.codexPlan { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.codexPlanLabel { + color: var(--text-tertiary); +} + +.codexPlanValue { + font-weight: 600; + color: var(--text-primary); + text-transform: capitalize; +} + // 单个认证文件卡片 .fileCard { background-color: var(--bg-primary); diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 5b150a7..1b78cf6 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -172,9 +172,60 @@ const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [ } ]; +interface CodexUsageWindow { + used_percent?: number | string; + usedPercent?: number | string; + limit_window_seconds?: number | string; + limitWindowSeconds?: number | string; + reset_after_seconds?: number | string; + resetAfterSeconds?: number | string; + reset_at?: number | string; + resetAt?: number | string; +} - -// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) +interface CodexRateLimitInfo { + allowed?: boolean; + limit_reached?: boolean; + primary_window?: CodexUsageWindow | null; + primaryWindow?: CodexUsageWindow | null; + secondary_window?: CodexUsageWindow | null; + secondaryWindow?: CodexUsageWindow | null; +} + +interface CodexUsagePayload { + plan_type?: string; + planType?: string; + rate_limit?: CodexRateLimitInfo | null; + rateLimit?: CodexRateLimitInfo | null; + code_review_rate_limit?: CodexRateLimitInfo | null; + codeReviewRateLimit?: CodexRateLimitInfo | null; +} + +interface CodexQuotaWindow { + id: string; + label: string; + usedPercent: number | null; + resetLabel: string; +} + +interface CodexQuotaState { + status: 'idle' | 'loading' | 'success' | 'error'; + windows: CodexQuotaWindow[]; + planType?: string | null; + error?: string; +} + +const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; + +const CODEX_REQUEST_HEADERS = { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json', + 'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal' +}; + + + +// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) function normalizeAuthIndexValue(value: unknown): string | null { if (typeof value === 'number' && Number.isFinite(value)) { return value.toString(); @@ -186,6 +237,146 @@ function normalizeAuthIndexValue(value: unknown): string | null { return null; } +function normalizeStringValue(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toString(); + } + return null; +} + +function normalizeNumberValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function normalizePlanType(value: unknown): string | null { + const normalized = normalizeStringValue(value); + return normalized ? normalized.toLowerCase() : null; +} + +function decodeBase64UrlPayload(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const normalized = trimmed.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + if (typeof window !== 'undefined' && typeof window.atob === 'function') { + return window.atob(padded); + } + if (typeof atob === 'function') { + return atob(padded); + } + } catch { + return null; + } + return null; +} + +function parseIdTokenPayload(value: unknown): Record | null { + if (!value) return null; + if (typeof value === 'object') { + return Array.isArray(value) ? null : (value as Record); + } + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed) as Record; + if (parsed && typeof parsed === 'object') return parsed; + } catch { + } + const segments = trimmed.split('.'); + if (segments.length < 2) return null; + const decoded = decodeBase64UrlPayload(segments[1]); + if (!decoded) return null; + try { + const parsed = JSON.parse(decoded) as Record; + if (parsed && typeof parsed === 'object') return parsed; + } catch { + return null; + } + return null; +} + +function extractCodexChatgptAccountId(value: unknown): string | null { + const payload = parseIdTokenPayload(value); + if (!payload) return null; + return normalizeStringValue(payload.chatgpt_account_id ?? payload.chatgptAccountId); +} + +function resolveCodexChatgptAccountId(file: AuthFileItem): string | null { + const metadata = + file && typeof file.metadata === 'object' && file.metadata !== null + ? (file.metadata as Record) + : null; + const attributes = + file && typeof file.attributes === 'object' && file.attributes !== null + ? (file.attributes as Record) + : null; + + const candidates = [file.id_token, metadata?.id_token, attributes?.id_token]; + + for (const candidate of candidates) { + const id = extractCodexChatgptAccountId(candidate); + if (id) return id; + } + + return null; +} + +function resolveCodexPlanType(file: AuthFileItem): string | null { + const metadata = + file && typeof file.metadata === 'object' && file.metadata !== null + ? (file.metadata as Record) + : null; + const attributes = + file && typeof file.attributes === 'object' && file.attributes !== null + ? (file.attributes as Record) + : null; + const idToken = + file && typeof file.id_token === 'object' && file.id_token !== null + ? (file.id_token as Record) + : null; + const metadataIdToken = + metadata && typeof metadata.id_token === 'object' && metadata.id_token !== null + ? (metadata.id_token as Record) + : null; + const candidates = [ + file.plan_type, + file.planType, + file['plan_type'], + file['planType'], + file.id_token, + idToken?.plan_type, + idToken?.planType, + metadata?.plan_type, + metadata?.planType, + metadata?.id_token, + metadataIdToken?.plan_type, + metadataIdToken?.planType, + attributes?.plan_type, + attributes?.planType, + attributes?.id_token + ]; + + for (const candidate of candidates) { + const planType = normalizePlanType(candidate); + if (planType) return planType; + } + + return null; +} + function parseAntigravityPayload(payload: unknown): Record | null { if (payload === undefined || payload === null) return null; if (typeof payload === 'string') { @@ -203,6 +394,23 @@ function parseAntigravityPayload(payload: unknown): Record | nu return null; } +function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null { + if (payload === undefined || payload === null) return null; + if (typeof payload === 'string') { + const trimmed = payload.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as CodexUsagePayload; + } catch { + return null; + } + } + if (typeof payload === 'object') { + return payload as CodexUsagePayload; + } + return null; +} + function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): { remainingFraction: number | null; resetTime?: string; @@ -326,6 +534,33 @@ function formatQuotaResetTime(value?: string): string { }); } +function formatUnixSeconds(value: number | null): string { + if (!value) return '-'; + const date = new Date(value * 1000); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleString(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); +} + +function formatCodexResetLabel(window?: CodexUsageWindow | null): string { + if (!window) return '-'; + const resetAt = normalizeNumberValue(window.reset_at ?? window.resetAt); + if (resetAt !== null && resetAt > 0) { + return formatUnixSeconds(resetAt); + } + const resetAfter = normalizeNumberValue(window.reset_after_seconds ?? window.resetAfterSeconds); + if (resetAfter !== null && resetAfter > 0) { + const targetSeconds = Math.floor(Date.now() / 1000 + resetAfter); + return formatUnixSeconds(targetSeconds); + } + return '-'; +} + function resolveAuthProvider(file: AuthFileItem): string { const raw = file.provider ?? file.type ?? ''; return String(raw).trim().toLowerCase(); @@ -335,6 +570,10 @@ function isAntigravityFile(file: AuthFileItem): boolean { return resolveAuthProvider(file) === 'antigravity'; } +function isCodexFile(file: AuthFileItem): boolean { + return resolveAuthProvider(file) === 'codex'; +} + function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { const raw = file['runtime_only'] ?? file.runtimeOnly; if (typeof raw === 'boolean') return raw; @@ -396,6 +635,8 @@ export function AuthFilesPage() { const [pageSize, setPageSize] = useState(9); const [antigravityPage, setAntigravityPage] = useState(1); const [antigravityPageSize, setAntigravityPageSize] = useState(6); + const [codexPage, setCodexPage] = useState(1); + const [codexPageSize, setCodexPageSize] = useState(6); const [uploading, setUploading] = useState(false); const [deleting, setDeleting] = useState(null); const [deletingAll, setDeletingAll] = useState(false); @@ -408,6 +649,9 @@ export function AuthFilesPage() { const [antigravityLoadingScope, setAntigravityLoadingScope] = useState< 'page' | 'all' | null >(null); + const [codexQuota, setCodexQuota] = useState>({}); + const [codexLoading, setCodexLoading] = useState(false); + const [codexLoadingScope, setCodexLoadingScope] = useState<'page' | 'all' | null>(null); // 详情弹窗相关 const [detailModalOpen, setDetailModalOpen] = useState(false); @@ -432,6 +676,8 @@ export function AuthFilesPage() { const loadingKeyStatsRef = useRef(false); const antigravityLoadingRef = useRef(false); const antigravityRequestIdRef = useRef(0); + const codexLoadingRef = useRef(false); + const codexRequestIdRef = useRef(0); const excludedUnsupportedRef = useRef(false); const disableControls = connectionStatus !== 'connected'; @@ -525,6 +771,12 @@ export function AuthFilesPage() { antigravityStart + antigravityPageSize ); + const codexFiles = useMemo(() => files.filter((file) => isCodexFile(file)), [files]); + const codexTotalPages = Math.max(1, Math.ceil(codexFiles.length / codexPageSize)); + const codexCurrentPage = Math.min(codexPage, codexTotalPages); + const codexStart = (codexCurrentPage - 1) * codexPageSize; + const codexPageItems = codexFiles.slice(codexStart, codexStart + codexPageSize); + const fetchAntigravityQuota = useCallback( async (authIndex: string): Promise => { let lastError = ''; @@ -646,6 +898,146 @@ export function AuthFilesPage() { [fetchAntigravityQuota, t] ); + const buildCodexQuotaWindows = useCallback( + (payload: CodexUsagePayload): CodexQuotaWindow[] => { + const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined; + const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined; + const windows: CodexQuotaWindow[] = []; + const addWindow = (id: string, label: string, window?: CodexUsageWindow | null) => { + if (!window) return; + const usedPercent = normalizeNumberValue(window.used_percent ?? window.usedPercent); + windows.push({ + id, + label, + usedPercent, + resetLabel: formatCodexResetLabel(window) + }); + }; + + addWindow('primary', t('codex_quota.primary_window'), rateLimit?.primary_window ?? rateLimit?.primaryWindow); + addWindow( + 'secondary', + t('codex_quota.secondary_window'), + rateLimit?.secondary_window ?? rateLimit?.secondaryWindow + ); + addWindow( + 'code-review', + t('codex_quota.code_review_window'), + codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow + ); + + return windows; + }, + [t] + ); + + const fetchCodexQuota = useCallback( + async (file: AuthFileItem): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndexValue(rawAuthIndex); + if (!authIndex) { + throw new Error(t('codex_quota.missing_auth_index')); + } + + const planTypeFromFile = resolveCodexPlanType(file); + const accountId = resolveCodexChatgptAccountId(file); + if (!accountId) { + throw new Error(t('codex_quota.missing_account_id')); + } + + const requestUsage = async (requestHeader: Record) => { + const result = await apiCallApi.request({ + authIndex, + method: 'GET', + url: CODEX_USAGE_URL, + header: requestHeader + }); + if (result.statusCode < 200 || result.statusCode >= 300) { + throw new Error(getApiCallErrorMessage(result)); + } + const payload = parseCodexUsagePayload(result.body ?? result.bodyText); + if (!payload) { + throw new Error(t('codex_quota.empty_windows')); + } + return payload; + }; + + const baseHeader: Record = { + ...CODEX_REQUEST_HEADERS, + 'Chatgpt-Account-Id': accountId + }; + + const payload = await requestUsage(baseHeader); + const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType); + const windows = buildCodexQuotaWindows(payload); + return { planType: planTypeFromUsage ?? planTypeFromFile, windows }; + }, + [buildCodexQuotaWindows, t] + ); + + const loadCodexQuota = useCallback( + async (targets: AuthFileItem[], scope: 'page' | 'all') => { + if (codexLoadingRef.current) return; + codexLoadingRef.current = true; + const requestId = ++codexRequestIdRef.current; + setCodexLoading(true); + setCodexLoadingScope(scope); + + try { + if (targets.length === 0) return; + + setCodexQuota((prev) => { + const nextState = { ...prev }; + targets.forEach((file) => { + nextState[file.name] = { status: 'loading', windows: [] }; + }); + return nextState; + }); + + const results = await Promise.all( + targets.map(async (file) => { + try { + const { planType, windows } = await fetchCodexQuota(file); + return { name: file.name, status: 'success' as const, planType, windows }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + return { name: file.name, status: 'error' as const, error: message }; + } + }) + ); + + if (requestId !== codexRequestIdRef.current) return; + + setCodexQuota((prev) => { + const nextState = { ...prev }; + results.forEach((result) => { + if (result.status === 'success') { + nextState[result.name] = { + status: 'success', + windows: result.windows, + planType: result.planType + }; + } else { + nextState[result.name] = { + status: 'error', + windows: [], + error: result.error + }; + } + }); + return nextState; + }); + } finally { + if (requestId === codexRequestIdRef.current) { + setCodexLoading(false); + setCodexLoadingScope(null); + codexLoadingRef.current = false; + } + } + }, + [fetchCodexQuota, t] + ); + useEffect(() => { loadFiles(); loadKeyStats(); @@ -668,6 +1060,23 @@ export function AuthFilesPage() { return nextState; }); }, [antigravityFiles]); + + useEffect(() => { + if (codexFiles.length === 0) { + setCodexQuota({}); + return; + } + setCodexQuota((prev) => { + const nextState: Record = {}; + codexFiles.forEach((file) => { + const cached = prev[file.name]; + if (cached) { + nextState[file.name] = cached; + } + }); + return nextState; + }); + }, [codexFiles]); // 定时刷新状态数据(每240秒) useInterval(loadKeyStats, 240_000); @@ -961,10 +1370,19 @@ export function AuthFilesPage() { }; // 获取类型颜色 - const getTypeColor = (type: string): ThemeColors => { - const set = TYPE_COLORS[type] || TYPE_COLORS.unknown; - return resolvedTheme === 'dark' && set.dark ? set.dark : set.light; - }; + const getTypeColor = (type: string): ThemeColors => { + const set = TYPE_COLORS[type] || TYPE_COLORS.unknown; + return resolvedTheme === 'dark' && set.dark ? set.dark : set.light; + }; + + const getCodexPlanLabel = (planType?: string | null): string | null => { + const normalized = normalizePlanType(planType); + if (!normalized) return null; + if (normalized === 'plus') return t('codex_quota.plan_plus'); + if (normalized === 'team') return t('codex_quota.plan_team'); + if (normalized === 'free') return t('codex_quota.plan_free'); + return planType || normalized; + }; // OAuth 排除相关方法 const openExcludedModal = (provider?: string) => { @@ -1276,6 +1694,97 @@ export function AuthFilesPage() { ); }; + const renderCodexCard = (item: AuthFileItem) => { + const displayType = item.type || item.provider || 'codex'; + const typeColor = getTypeColor(displayType); + const quotaState = codexQuota[item.name]; + const quotaStatus = quotaState?.status ?? 'idle'; + const windows = quotaState?.windows ?? []; + const planType = quotaState?.planType ?? null; + const planLabel = getCodexPlanLabel(planType); + const isFreePlan = normalizePlanType(planType) === 'free'; + + return ( +
+
+ + {getTypeLabel(displayType)} + + {item.name} +
+ +
+ {quotaStatus === 'loading' ? ( +
{t('codex_quota.loading')}
+ ) : quotaStatus === 'idle' ? ( +
{t('codex_quota.idle')}
+ ) : quotaStatus === 'error' ? ( +
+ {t('codex_quota.load_failed', { + message: quotaState?.error || t('common.unknown_error') + })} +
+ ) : ( + <> + {planLabel && ( +
+ {t('codex_quota.plan_label')} + {planLabel} +
+ )} + {isFreePlan ? ( +
{t('codex_quota.no_access')}
+ ) : windows.length === 0 ? ( +
{t('codex_quota.empty_windows')}
+ ) : ( + windows.map((window) => { + const used = window.usedPercent; + const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used)); + const remaining = + clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed)); + const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`; + const quotaBarClass = + remaining === null + ? styles.quotaBarFillMedium + : remaining >= 80 + ? styles.quotaBarFillHigh + : remaining >= 50 + ? styles.quotaBarFillMedium + : styles.quotaBarFillLow; + + return ( +
+
+ {window.label} +
+ {percentLabel} + {window.resetLabel} +
+
+
+
+
+
+ ); + }) + )} + + )} +
+
+ ); + }; + return (
@@ -1491,10 +2000,96 @@ export function AuthFilesPage() { )} + + + +
+ } + > + {codexFiles.length === 0 ? ( + + ) : ( + <> +
+
+ + +
+
+ +
+ {codexFiles.length} {t('auth_files.files_count')} +
+
+
+
{codexPageItems.map(renderCodexCard)}
+ {codexFiles.length > codexPageSize && ( +
+ +
+ {t('auth_files.pagination_info', { + current: codexCurrentPage, + total: codexTotalPages, + count: codexFiles.length + })} +
+ +
+ )} + + )} + + {/* OAuth 排除列表卡片 */} openExcludedModal()}