From 95cbfb8c59cf7c2a84dca006954d45dfebebe74a Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sun, 28 Dec 2025 23:39:26 +0800 Subject: [PATCH] feat(auth-files): add antigravity quota cards with grouping, pagination, and i18n --- .gitignore | 1 + src/i18n/locales/en.json | 9 + src/i18n/locales/zh-CN.json | 9 + src/pages/AuthFilesPage.module.scss | 128 ++++++ src/pages/AuthFilesPage.tsx | 629 +++++++++++++++++++++++++--- 5 files changed, 720 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index eb4e16d..c1d7510 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ api.md usage.json CLAUDE.md AGENTS.md +antigravity_usage.json node_modules dist diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6cfdaa5..bf3ff5a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -357,6 +357,15 @@ "models_excluded_badge": "Excluded", "models_excluded_hint": "This model is excluded by OAuth" }, + "antigravity_quota": { + "title": "Antigravity Quota", + "empty_title": "No Antigravity Auth Files", + "empty_desc": "Upload an Antigravity credential to view remaining quota.", + "loading": "Loading quota...", + "load_failed": "Failed to load quota: {{message}}", + "missing_auth_index": "Auth file missing auth_index", + "empty_models": "No quota data available" + }, "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 563da42..9d110e4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -357,6 +357,15 @@ "models_excluded_badge": "已排除", "models_excluded_hint": "此模型已被 OAuth 排除" }, + "antigravity_quota": { + "title": "Antigravity 额度", + "empty_title": "暂无 Antigravity 认证", + "empty_desc": "上传 Antigravity 认证文件后即可查看额度。", + "loading": "正在加载额度...", + "load_failed": "额度获取失败:{{message}}", + "missing_auth_index": "认证文件缺少 auth_index", + "empty_models": "暂无额度数据" + }, "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 b9b598c..7121a1c 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -162,6 +162,134 @@ } } +.antigravityGrid { + 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; + } +} + +.antigravityCard { + background-image: linear-gradient( + 180deg, + rgba(224, 247, 250, 0.12), + rgba(224, 247, 250, 0) + ); +} + +.quotaSection { + display: flex; + flex-direction: column; + gap: $spacing-sm; + padding-top: $spacing-sm; + margin-top: $spacing-xs; + border-top: 1px dashed var(--border-color); +} + +.quotaRow { + display: flex; + flex-direction: column; + gap: $spacing-xs; +} + +.quotaRowHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + min-width: 0; + + @include mobile { + flex-direction: column; + align-items: flex-start; + } +} + +.quotaModel { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + + @include mobile { + white-space: normal; + } +} + +.quotaBar { + height: 8px; + background-color: var(--bg-tertiary); + border-radius: 999px; + overflow: hidden; +} + +.quotaBarFill { + height: 100%; + background-color: var(--success-color, #22c55e); + transition: width 0.2s ease; +} + +.quotaBarFillHigh { + background-color: var(--success-color, #22c55e); +} + +.quotaBarFillMedium { + background-color: var(--warning-color, #f59e0b); +} + +.quotaBarFillLow { + background-color: var(--danger-color, #ef4444); +} + +.quotaMeta { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + + @include mobile { + justify-content: flex-start; + } +} + +.quotaPercent { + font-weight: 600; + color: var(--text-primary); +} + +.quotaReset { + color: var(--text-tertiary); +} + +.quotaMessage { + font-size: 12px; + color: var(--text-tertiary); + text-align: center; + padding: $spacing-sm 0; +} + +.quotaError { + font-size: 12px; + color: var(--danger-color); + background-color: rgba(239, 68, 68, 0.08); + border: 1px solid var(--danger-color); + border-radius: $radius-sm; + padding: $spacing-xs $spacing-sm; +} + // 单个认证文件卡片 .fileCard { background-color: var(--bg-primary); diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index d0f5864..4dbdd94 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -9,7 +9,7 @@ import { Modal } from '@/components/ui/Modal'; import { EmptyState } from '@/components/ui/EmptyState'; import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons'; import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; -import { authFilesApi, usageApi } from '@/services/api'; +import { apiCallApi, authFilesApi, getApiCallErrorMessage, usageApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; import type { AuthFileItem } from '@/types'; import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage'; @@ -83,21 +83,258 @@ interface ExcludedFormState { provider: string; modelsText: string; } + +interface AntigravityQuotaGroup { + id: string; + label: string; + models: string[]; + remainingFraction: number; + resetTime?: string; +} + +interface AntigravityQuotaState { + status: 'idle' | 'loading' | 'success' | 'error'; + groups: AntigravityQuotaGroup[]; + error?: string; +} + +interface AntigravityQuotaInfo { + displayName?: string; + quotaInfo?: { + remainingFraction?: number | string; + remaining_fraction?: number | string; + remaining?: number | string; + resetTime?: string; + reset_time?: string; + }; + quota_info?: { + remainingFraction?: number | string; + remaining_fraction?: number | string; + remaining?: number | string; + resetTime?: string; + reset_time?: string; + }; +} + +type AntigravityModelsPayload = Record; + +interface AntigravityQuotaGroupDefinition { + id: string; + label: string; + identifiers: string[]; + labelFromModel?: boolean; +} + +const ANTIGRAVITY_QUOTA_URLS = [ + 'https://cloudcode-pa-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels', + 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels', + 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels' +]; + +const ANTIGRAVITY_REQUEST_HEADERS = { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json', + 'User-Agent': 'antigravity/1.11.5 windows/amd64' +}; + +const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [ + { + id: 'claude-gpt', + label: 'Claude/GPT', + identifiers: [ + 'claude-sonnet-4-5-thinking', + 'claude-opus-4-5-thinking', + 'claude-sonnet-4-5', + 'gpt-oss-120b-medium' + ] + }, + { + id: 'gemini', + label: 'Gemini', + identifiers: [ + 'gemini-3-pro-high', + 'gemini-3-pro-low', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + 'rev19-uic3-1p' + ] + }, + { + id: 'gemini-3-flash', + label: 'Gemini 3 Flash', + identifiers: ['gemini-3-flash'] + }, + { + id: 'gemini-image', + label: 'gemini-3-pro-image', + identifiers: ['gemini-3-pro-image'], + labelFromModel: true + } +]; // 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) -function normalizeAuthIndexValue(value: unknown): string | null { - if (typeof value === 'number' && Number.isFinite(value)) { - return value.toString(); - } +function normalizeAuthIndexValue(value: unknown): string | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toString(); + } if (typeof value === 'string') { const trimmed = value.trim(); return trimmed ? trimmed : null; } - return null; -} - -function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { - const raw = file['runtime_only'] ?? file.runtimeOnly; + return null; +} + +function parseAntigravityPayload(payload: unknown): Record | 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 Record; + } catch { + return null; + } + } + if (typeof payload === 'object') { + return payload as Record; + } + return null; +} + +function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): { + remainingFraction: number | null; + resetTime?: string; + displayName?: string; +} { + if (!entry) { + return { remainingFraction: null }; + } + const quotaInfo = entry.quotaInfo ?? entry.quota_info ?? {}; + const remainingValue = + quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining; + const remainingFraction = Number(remainingValue); + const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time; + const resetTime = typeof resetValue === 'string' ? resetValue : undefined; + const displayName = typeof entry.displayName === 'string' ? entry.displayName : undefined; + + return { + remainingFraction: Number.isFinite(remainingFraction) ? remainingFraction : null, + resetTime, + displayName + }; +} + +function findAntigravityModel( + models: AntigravityModelsPayload, + identifier: string +): { id: string; entry: AntigravityQuotaInfo } | null { + const direct = models[identifier]; + if (direct) { + return { id: identifier, entry: direct }; + } + + const match = Object.entries(models).find(([, entry]) => { + const name = typeof entry?.displayName === 'string' ? entry.displayName : ''; + return name.toLowerCase() === identifier.toLowerCase(); + }); + if (match) { + return { id: match[0], entry: match[1] }; + } + + return null; +} + +function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): AntigravityQuotaGroup[] { + const groups: AntigravityQuotaGroup[] = []; + let geminiResetTime: string | undefined; + const [claudeDef, geminiDef, flashDef, imageDef] = ANTIGRAVITY_QUOTA_GROUPS; + + const buildGroup = ( + def: AntigravityQuotaGroupDefinition, + overrideResetTime?: string + ): AntigravityQuotaGroup | null => { + const matches = def.identifiers + .map((identifier) => findAntigravityModel(models, identifier)) + .filter((entry): entry is { id: string; entry: AntigravityQuotaInfo } => Boolean(entry)); + + const quotaEntries = matches + .map(({ id, entry }) => { + const info = getAntigravityQuotaInfo(entry); + if (info.remainingFraction === null) return null; + return { + id, + remainingFraction: info.remainingFraction, + resetTime: info.resetTime, + displayName: info.displayName + }; + }) + .filter((entry): entry is NonNullable => entry !== null); + + if (quotaEntries.length === 0) return null; + + const remainingFraction = Math.min(...quotaEntries.map((entry) => entry.remainingFraction)); + const resetTime = + overrideResetTime ?? quotaEntries.map((entry) => entry.resetTime).find(Boolean); + const displayName = quotaEntries.map((entry) => entry.displayName).find(Boolean); + const label = def.labelFromModel && displayName ? displayName : def.label; + + return { + id: def.id, + label, + models: quotaEntries.map((entry) => entry.id), + remainingFraction, + resetTime + }; + }; + + const claudeGroup = buildGroup(claudeDef); + if (claudeGroup) { + groups.push(claudeGroup); + } + + const geminiGroup = buildGroup(geminiDef); + if (geminiGroup) { + geminiResetTime = geminiGroup.resetTime; + groups.push(geminiGroup); + } + + const flashGroup = buildGroup(flashDef); + if (flashGroup) { + groups.push(flashGroup); + } + + const imageGroup = buildGroup(imageDef, geminiResetTime); + if (imageGroup) { + groups.push(imageGroup); + } + + return groups; +} + +function formatQuotaResetTime(value?: string): string { + if (!value) return '-'; + const date = new Date(value); + 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 resolveAuthProvider(file: AuthFileItem): string { + const raw = file.provider ?? file.type ?? ''; + return String(raw).trim().toLowerCase(); +} + +function isAntigravityFile(file: AuthFileItem): boolean { + return resolveAuthProvider(file) === 'antigravity'; +} + +function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { + const raw = file['runtime_only'] ?? file.runtimeOnly; if (typeof raw === 'boolean') return raw; if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true'; return false; @@ -151,15 +388,20 @@ export function AuthFilesPage() { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [filter, setFilter] = useState<'all' | string>('all'); - const [search, setSearch] = useState(''); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(9); - const [uploading, setUploading] = useState(false); - const [deleting, setDeleting] = useState(null); - const [deletingAll, setDeletingAll] = useState(false); - const [keyStats, setKeyStats] = useState({ bySource: {}, byAuthIndex: {} }); - const [usageDetails, setUsageDetails] = useState([]); + const [filter, setFilter] = useState<'all' | string>('all'); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(9); + const [antigravityPage, setAntigravityPage] = useState(1); + const [uploading, setUploading] = useState(false); + const [deleting, setDeleting] = useState(null); + const [deletingAll, setDeletingAll] = useState(false); + const [keyStats, setKeyStats] = useState({ bySource: {}, byAuthIndex: {} }); + const [usageDetails, setUsageDetails] = useState([]); + const [antigravityQuota, setAntigravityQuota] = useState>( + {} + ); + const [antigravityLoading, setAntigravityLoading] = useState(false); // 详情弹窗相关 const [detailModalOpen, setDetailModalOpen] = useState(false); @@ -180,8 +422,10 @@ export function AuthFilesPage() { const [excludedForm, setExcludedForm] = useState({ provider: '', modelsText: '' }); const [savingExcluded, setSavingExcluded] = useState(false); - const fileInputRef = useRef(null); - const loadingKeyStatsRef = useRef(false); + const fileInputRef = useRef(null); + const loadingKeyStatsRef = useRef(false); + const antigravityLoadingRef = useRef(false); + const antigravityRequestIdRef = useRef(0); const excludedUnsupportedRef = useRef(false); const disableControls = connectionStatus !== 'connected'; @@ -234,11 +478,11 @@ export function AuthFilesPage() { }, []); // 加载 OAuth 排除列表 - const loadExcluded = useCallback(async () => { - try { - const res = await authFilesApi.getOauthExcludedModels(); - excludedUnsupportedRef.current = false; - setExcluded(res || {}); + const loadExcluded = useCallback(async () => { + try { + const res = await authFilesApi.getOauthExcludedModels(); + excludedUnsupportedRef.current = false; + setExcluded(res || {}); setExcludedError(null); } catch (err: unknown) { const status = @@ -255,30 +499,175 @@ export function AuthFilesPage() { } return; } - // 静默失败 - } - }, [showNotification, t]); + // 静默失败 + } + }, [showNotification, t]); + + const antigravityFiles = useMemo( + () => files.filter((file) => isAntigravityFile(file)), + [files] + ); + + const antigravityPageSize = 6; + const antigravityTotalPages = Math.max( + 1, + Math.ceil(antigravityFiles.length / antigravityPageSize) + ); + const antigravityCurrentPage = Math.min(antigravityPage, antigravityTotalPages); + const antigravityStart = (antigravityCurrentPage - 1) * antigravityPageSize; + const antigravityPageItems = antigravityFiles.slice( + antigravityStart, + antigravityStart + antigravityPageSize + ); + + const fetchAntigravityQuota = useCallback( + async (authIndex: string): Promise => { + let lastError = ''; + let hadSuccess = false; + + for (const url of ANTIGRAVITY_QUOTA_URLS) { + try { + const result = await apiCallApi.request({ + authIndex, + method: 'POST', + url, + header: { ...ANTIGRAVITY_REQUEST_HEADERS }, + data: '{}' + }); + + if (result.statusCode < 200 || result.statusCode >= 300) { + lastError = getApiCallErrorMessage(result); + continue; + } + + hadSuccess = true; + const payload = parseAntigravityPayload(result.body ?? result.bodyText); + const models = payload?.models; + if (!models || typeof models !== 'object' || Array.isArray(models)) { + lastError = t('antigravity_quota.empty_models'); + continue; + } + + const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload); + if (groups.length === 0) { + lastError = t('antigravity_quota.empty_models'); + continue; + } + + return groups; + } catch (err: unknown) { + lastError = err instanceof Error ? err.message : t('common.unknown_error'); + } + } + + if (hadSuccess) { + return []; + } + + throw new Error(lastError || t('common.unknown_error')); + }, + [t] + ); + + const loadAntigravityQuota = useCallback(async () => { + if (antigravityLoadingRef.current) return; + antigravityLoadingRef.current = true; + const requestId = ++antigravityRequestIdRef.current; + setAntigravityLoading(true); + + try { + if (antigravityFiles.length === 0) { + setAntigravityQuota({}); + return; + } + + const loadingState: Record = {}; + antigravityFiles.forEach((file) => { + loadingState[file.name] = { status: 'loading', groups: [] }; + }); + setAntigravityQuota(loadingState); + + const results = await Promise.all( + antigravityFiles.map(async (file) => { + const rawAuthIndex = file['auth_index'] ?? file.authIndex; + const authIndex = normalizeAuthIndexValue(rawAuthIndex); + if (!authIndex) { + return { + name: file.name, + status: 'error' as const, + error: t('antigravity_quota.missing_auth_index') + }; + } + + try { + const groups = await fetchAntigravityQuota(authIndex); + return { name: file.name, status: 'success' as const, groups }; + } 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 !== antigravityRequestIdRef.current) return; + + const nextState: Record = {}; + results.forEach((result) => { + if (result.status === 'success') { + nextState[result.name] = { + status: 'success', + groups: result.groups + }; + } else { + nextState[result.name] = { + status: 'error', + groups: [], + error: result.error + }; + } + }); + setAntigravityQuota(nextState); + } finally { + if (requestId === antigravityRequestIdRef.current) { + setAntigravityLoading(false); + antigravityLoadingRef.current = false; + } + } + }, [antigravityFiles, fetchAntigravityQuota, t]); + + useEffect(() => { + loadFiles(); + loadKeyStats(); + loadExcluded(); + }, [loadFiles, loadKeyStats, loadExcluded]); + + useEffect(() => { + if (antigravityFiles.length === 0) { + setAntigravityQuota({}); + return; + } + loadAntigravityQuota(); + }, [antigravityFiles, loadAntigravityQuota]); - useEffect(() => { - loadFiles(); - loadKeyStats(); - loadExcluded(); - }, [loadFiles, loadKeyStats, loadExcluded]); - - // 定时刷新状态数据(每240秒) - useInterval(loadKeyStats, 240_000); + // 定时刷新状态数据(每240秒) + useInterval(loadKeyStats, 240_000); + useInterval(() => { + if (antigravityFiles.length === 0) return; + loadAntigravityQuota(); + }, 240_000); // 提取所有存在的类型 const existingTypes = useMemo(() => { const types = new Set(['all']); files.forEach((file) => { - if (file.type) { - types.add(file.type); - } - }); + if (file.type) { + types.add(file.type); + } + }); return Array.from(types); }, [files]); + const excludedProviderLookup = useMemo(() => { const lookup = new Map(); Object.keys(excluded).forEach((provider) => { @@ -704,10 +1093,10 @@ export function AuthFilesPage() { }; // 渲染单个认证文件卡片 - const renderFileCard = (item: AuthFileItem) => { - const fileStats = resolveAuthFileStats(item, keyStats); - const isRuntimeOnly = isRuntimeOnlyAuthFile(item); - const typeColor = getTypeColor(item.type || 'unknown'); + const renderFileCard = (item: AuthFileItem) => { + const fileStats = resolveAuthFileStats(item, keyStats); + const isRuntimeOnly = isRuntimeOnlyAuthFile(item); + const typeColor = getTypeColor(item.type || 'unknown'); return (
@@ -794,12 +1183,83 @@ export function AuthFilesPage() { )}
- - ); - }; - - return ( -
+
+ ); + }; + + const renderAntigravityCard = (item: AuthFileItem) => { + const displayType = item.type || item.provider || 'antigravity'; + const typeColor = getTypeColor(displayType); + const quotaState = antigravityQuota[item.name]; + const quotaStatus = quotaState?.status ?? 'idle'; + const quotaGroups = quotaState?.groups ?? []; + + return ( +
+
+ + {getTypeLabel(displayType)} + + {item.name} +
+ +
+ {quotaStatus === 'loading' || quotaStatus === 'idle' ? ( +
{t('antigravity_quota.loading')}
+ ) : quotaStatus === 'error' ? ( +
+ {t('antigravity_quota.load_failed', { + message: quotaState?.error || t('common.unknown_error') + })} +
+ ) : quotaGroups.length === 0 ? ( +
{t('antigravity_quota.empty_models')}
+ ) : ( + quotaGroups.map((group) => { + const clamped = Math.max(0, Math.min(1, group.remainingFraction)); + const percent = Math.round(clamped * 100); + const resetLabel = formatQuotaResetTime(group.resetTime); + const quotaBarClass = + percent >= 60 + ? styles.quotaBarFillHigh + : percent >= 20 + ? styles.quotaBarFillMedium + : styles.quotaBarFillLow; + return ( +
+
+ + {group.label} + +
+ {percent}% + {resetLabel} +
+
+
+
+
+
+ ); + }) + )} +
+
+ ); + }; + + return ( +

{t('auth_files.title')}

{t('auth_files.description')}

@@ -918,11 +1378,68 @@ export function AuthFilesPage() {
)} - - - {/* OAuth 排除列表卡片 */} - + + + {t('common.refresh')} + + } + > + {antigravityFiles.length === 0 ? ( + + ) : ( + <> +
+ {antigravityPageItems.map(renderAntigravityCard)} +
+ {antigravityFiles.length > antigravityPageSize && ( +
+ +
+ {t('auth_files.pagination_info', { + current: antigravityCurrentPage, + total: antigravityTotalPages, + count: antigravityFiles.length + })} +
+ +
+ )} + + )} +
+ + {/* OAuth 排除列表卡片 */} +