diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index ac938f8c..58715706 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -3,6 +3,7 @@ package admin import ( "strconv" "strings" + "time" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -989,3 +990,167 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { response.Success(c, models) } + +// RefreshTier handles refreshing Google One tier for a single account +// POST /api/v1/admin/accounts/:id/refresh-tier +func (h *AccountHandler) RefreshTier(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + response.NotFound(c, "Account not found") + return + } + + if account.Credentials == nil || account.Credentials["oauth_type"] != "google_one" { + response.BadRequest(c, "Account is not a google_one OAuth account") + return + } + + accessToken, ok := account.Credentials["access_token"].(string) + if !ok || accessToken == "" { + response.BadRequest(c, "Missing access_token in credentials") + return + } + + var proxyURL string + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + tierID, storageInfo, err := h.geminiOAuthService.FetchGoogleOneTier(c.Request.Context(), accessToken, proxyURL) + + if account.Extra == nil { + account.Extra = make(map[string]any) + } + if storageInfo != nil { + account.Extra["drive_storage_limit"] = storageInfo.Limit + account.Extra["drive_storage_usage"] = storageInfo.Usage + account.Extra["drive_tier_updated_at"] = timezone.Now().Format(time.RFC3339) + } + account.Credentials["tier_id"] = tierID + + _, updateErr := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{ + Credentials: account.Credentials, + Extra: account.Extra, + }) + if updateErr != nil { + response.ErrorFrom(c, updateErr) + return + } + + response.Success(c, gin.H{ + "tier_id": tierID, + "drive_storage_limit": account.Extra["drive_storage_limit"], + "drive_storage_usage": account.Extra["drive_storage_usage"], + "updated_at": account.Extra["drive_tier_updated_at"], + }) +} + +// BatchRefreshTierRequest represents batch tier refresh request +type BatchRefreshTierRequest struct { + AccountIDs []int64 `json:"account_ids"` +} + +// BatchRefreshTier handles batch refreshing Google One tier +// POST /api/v1/admin/accounts/batch-refresh-tier +func (h *AccountHandler) BatchRefreshTier(c *gin.Context) { + var req BatchRefreshTierRequest + if err := c.ShouldBindJSON(&req); err != nil { + req = BatchRefreshTierRequest{} + } + + ctx := c.Request.Context() + var accounts []service.Account + + if len(req.AccountIDs) == 0 { + allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "") + if err != nil { + response.ErrorFrom(c, err) + return + } + for _, acc := range allAccounts { + if acc.Credentials != nil && acc.Credentials["oauth_type"] == "google_one" { + accounts = append(accounts, acc) + } + } + } else { + for _, id := range req.AccountIDs { + acc, err := h.adminService.GetAccount(ctx, id) + if err != nil { + continue + } + if acc.Credentials != nil && acc.Credentials["oauth_type"] == "google_one" { + accounts = append(accounts, *acc) + } + } + } + + total := len(accounts) + success := 0 + failed := 0 + errors := []gin.H{} + + for _, account := range accounts { + accessToken, ok := account.Credentials["access_token"].(string) + if !ok || accessToken == "" { + failed++ + errors = append(errors, gin.H{ + "account_id": account.ID, + "error": "missing access_token", + }) + continue + } + + var proxyURL string + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + tierID, storageInfo, err := h.geminiOAuthService.FetchGoogleOneTier(ctx, accessToken, proxyURL) + if err != nil { + failed++ + errors = append(errors, gin.H{ + "account_id": account.ID, + "error": err.Error(), + }) + continue + } + + if account.Extra == nil { + account.Extra = make(map[string]any) + } + if storageInfo != nil { + account.Extra["drive_storage_limit"] = storageInfo.Limit + account.Extra["drive_storage_usage"] = storageInfo.Usage + account.Extra["drive_tier_updated_at"] = timezone.Now().Format(time.RFC3339) + } + account.Credentials["tier_id"] = tierID + + _, updateErr := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{ + Credentials: account.Credentials, + Extra: account.Extra, + }) + if updateErr != nil { + failed++ + errors = append(errors, gin.H{ + "account_id": account.ID, + "error": updateErr.Error(), + }) + continue + } + + success++ + } + + response.Success(c, gin.H{ + "total": total, + "success": success, + "failed": failed, + "errors": errors, + }) +} diff --git a/backend/internal/handler/admin/gemini_oauth_handler.go b/backend/internal/handler/admin/gemini_oauth_handler.go index 4440aa21..037800e2 100644 --- a/backend/internal/handler/admin/gemini_oauth_handler.go +++ b/backend/internal/handler/admin/gemini_oauth_handler.go @@ -46,8 +46,8 @@ func (h *GeminiOAuthHandler) GenerateAuthURL(c *gin.Context) { if oauthType == "" { oauthType = "code_assist" } - if oauthType != "code_assist" && oauthType != "ai_studio" { - response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") + if oauthType != "code_assist" && oauthType != "google_one" && oauthType != "ai_studio" { + response.BadRequest(c, "Invalid oauth_type: must be 'code_assist', 'google_one', or 'ai_studio'") return } @@ -92,8 +92,8 @@ func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) { if oauthType == "" { oauthType = "code_assist" } - if oauthType != "code_assist" && oauthType != "ai_studio" { - response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") + if oauthType != "code_assist" && oauthType != "google_one" && oauthType != "ai_studio" { + response.BadRequest(c, "Invalid oauth_type: must be 'code_assist', 'google_one', or 'ai_studio'") return } diff --git a/backend/internal/pkg/geminicli/drive_client.go b/backend/internal/pkg/geminicli/drive_client.go new file mode 100644 index 00000000..5a959fac --- /dev/null +++ b/backend/internal/pkg/geminicli/drive_client.go @@ -0,0 +1,113 @@ +package geminicli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" +) + +// DriveStorageInfo represents Google Drive storage quota information +type DriveStorageInfo struct { + Limit int64 `json:"limit"` // Storage limit in bytes + Usage int64 `json:"usage"` // Current usage in bytes +} + +// DriveClient interface for Google Drive API operations +type DriveClient interface { + GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*DriveStorageInfo, error) +} + +type driveClient struct { + httpClient *http.Client +} + +// NewDriveClient creates a new Drive API client +func NewDriveClient() DriveClient { + return &driveClient{ + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// GetStorageQuota fetches storage quota from Google Drive API +func (c *driveClient) GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*DriveStorageInfo, error) { + const driveAPIURL = "https://www.googleapis.com/drive/v3/about?fields=storageQuota" + + req, err := http.NewRequestWithContext(ctx, "GET", driveAPIURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + // Get HTTP client with proxy support + client, err := httpclient.GetClient(httpclient.Options{ + ProxyURL: proxyURL, + Timeout: 10 * time.Second, + }) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client: %w", err) + } + + // Retry logic with exponential backoff for rate limits + var resp *http.Response + maxRetries := 3 + for attempt := 0; attempt < maxRetries; attempt++ { + resp, err = client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + + // Success + if resp.StatusCode == http.StatusOK { + break + } + + // Rate limit - retry with exponential backoff + if resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries-1 { + resp.Body.Close() + backoff := time.Duration(1< 100*1024*1024*1024*1024 { // > 100TB + return TierGoogleOneUnlimited + } + + // AI Premium (2TB+) + if storageBytes >= 2*1024*1024*1024*1024 { // >= 2TB + return TierAIPremium + } + + // Google One Standard (200GB) + if storageBytes >= 200*1024*1024*1024 { // >= 200GB + return TierGoogleOneStandard + } + + // Google One Basic (100GB) + if storageBytes >= 100*1024*1024*1024 { // >= 100GB + return TierGoogleOneBasic + } + + // Free (15GB) + if storageBytes >= 15*1024*1024*1024 { // >= 15GB + return TierFree + } + + return TierGoogleOneUnknown +} + +// fetchGoogleOneTier fetches Google One tier from Drive API +func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken, proxyURL string) (string, *geminicli.DriveStorageInfo, error) { + driveClient := geminicli.NewDriveClient() + + storageInfo, err := driveClient.GetStorageQuota(ctx, accessToken, proxyURL) + if err != nil { + // Check if it's a 403 (scope not granted) + if strings.Contains(err.Error(), "status 403") { + fmt.Printf("[GeminiOAuth] Drive API scope not available: %v\n", err) + return TierGoogleOneUnknown, nil, err + } + // Other errors + fmt.Printf("[GeminiOAuth] Failed to fetch Drive storage: %v\n", err) + return TierGoogleOneUnknown, nil, err + } + + tierID := inferGoogleOneTier(storageInfo.Limit) + return tierID, storageInfo, nil +} + func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) { session, ok := s.sessionStore.Get(input.SessionID) if !ok { @@ -272,7 +337,8 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch projectID := sessionProjectID var tierID string - // 对于 code_assist 模式,project_id 是必需的 + // 对于 code_assist 模式,project_id 是必需的,需要调用 Code Assist API + // 对于 google_one 模式,使用个人 Google 账号,不需要 project_id,配额由 Google 网关自动识别 // 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API) if oauthType == "code_assist" { if projectID == "" { @@ -298,7 +364,37 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch if tierID == "" { tierID = "LEGACY" } + } else if oauthType == "google_one" { + // Attempt to fetch Drive storage tier + tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, tokenResp.AccessToken, proxyURL) + if err != nil { + // Log warning but don't block - use fallback + fmt.Printf("[GeminiOAuth] Warning: Failed to fetch Drive tier: %v\n", err) + tierID = TierGoogleOneUnknown + } + + // Store Drive info in extra field for caching + if storageInfo != nil { + tokenInfo := &GeminiTokenInfo{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + TokenType: tokenResp.TokenType, + ExpiresIn: tokenResp.ExpiresIn, + ExpiresAt: expiresAt, + Scope: tokenResp.Scope, + ProjectID: projectID, + TierID: tierID, + OAuthType: oauthType, + Extra: map[string]any{ + "drive_storage_limit": storageInfo.Limit, + "drive_storage_usage": storageInfo.Usage, + "drive_tier_updated_at": time.Now().Format(time.RFC3339), + }, + } + return tokenInfo, nil + } } + // ai_studio 模式不设置 tierID,保持为空 return &GeminiTokenInfo{ AccessToken: tokenResp.AccessToken, @@ -455,6 +551,41 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A if strings.TrimSpace(tokenInfo.ProjectID) == "" { return nil, fmt.Errorf("failed to auto-detect project_id: empty result") } + } else if oauthType == "google_one" { + // Check if tier cache is stale (> 24 hours) + needsRefresh := true + if account.Extra != nil { + if updatedAtStr, ok := account.Extra["drive_tier_updated_at"].(string); ok { + if updatedAt, err := time.Parse(time.RFC3339, updatedAtStr); err == nil { + if time.Since(updatedAt) <= 24*time.Hour { + needsRefresh = false + // Use cached tier + if existingTierID != "" { + tokenInfo.TierID = existingTierID + } + } + } + } + } + + if needsRefresh { + tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, tokenInfo.AccessToken, proxyURL) + if err == nil && storageInfo != nil { + tokenInfo.TierID = tierID + tokenInfo.Extra = map[string]any{ + "drive_storage_limit": storageInfo.Limit, + "drive_storage_usage": storageInfo.Usage, + "drive_tier_updated_at": time.Now().Format(time.RFC3339), + } + } else { + // Fallback to cached or unknown + if existingTierID != "" { + tokenInfo.TierID = existingTierID + } else { + tokenInfo.TierID = TierGoogleOneUnknown + } + } + } } return tokenInfo, nil @@ -487,6 +618,12 @@ func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo) if tokenInfo.OAuthType != "" { creds["oauth_type"] = tokenInfo.OAuthType } + // Store extra metadata (Drive info) if present + if len(tokenInfo.Extra) > 0 { + for k, v := range tokenInfo.Extra { + creds[k] = v + } + } return creds } diff --git a/frontend/src/components/account/AccountQuotaInfo.vue b/frontend/src/components/account/AccountQuotaInfo.vue index c20d685d..512b4451 100644 --- a/frontend/src/components/account/AccountQuotaInfo.vue +++ b/frontend/src/components/account/AccountQuotaInfo.vue @@ -48,6 +48,12 @@ const isCodeAssist = computed(() => { return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id) }) +// 是否为 Google One OAuth +const isGoogleOne = computed(() => { + const creds = props.account.credentials as GeminiCredentials | undefined + return creds?.oauth_type === 'google_one' +}) + // 是否应该显示配额信息 const shouldShowQuota = computed(() => { return props.account.platform === 'gemini' @@ -55,33 +61,73 @@ const shouldShowQuota = computed(() => { // Tier 标签文本 const tierLabel = computed(() => { + const creds = props.account.credentials as GeminiCredentials | undefined + if (isCodeAssist.value) { - const creds = props.account.credentials as GeminiCredentials | undefined + // GCP Code Assist: 显示 GCP tier const tierMap: Record = { LEGACY: 'Free', PRO: 'Pro', - ULTRA: 'Ultra' + ULTRA: 'Ultra', + 'standard-tier': 'Standard', + 'pro-tier': 'Pro', + 'ultra-tier': 'Ultra' } - return tierMap[creds?.tier_id || ''] || 'Unknown' + return tierMap[creds?.tier_id || ''] || (creds?.tier_id ? 'GCP' : 'Unknown') } + + if (isGoogleOne.value) { + // Google One: tier 映射 + const tierMap: Record = { + AI_PREMIUM: 'AI Premium', + GOOGLE_ONE_STANDARD: 'Standard', + GOOGLE_ONE_BASIC: 'Basic', + FREE: 'Free', + GOOGLE_ONE_UNKNOWN: 'Personal', + GOOGLE_ONE_UNLIMITED: 'Unlimited' + } + return tierMap[creds?.tier_id || ''] || 'Personal' + } + + // AI Studio 或其他 return 'Gemini' }) // Tier Badge 样式 const tierBadgeClass = computed(() => { - if (!isCodeAssist.value) { - return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' - } const creds = props.account.credentials as GeminiCredentials | undefined - const tierColorMap: Record = { - LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', - PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' + + if (isCodeAssist.value) { + // GCP Code Assist 样式 + const tierColorMap: Record = { + LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', + PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + 'standard-tier': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + 'pro-tier': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'ultra-tier': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' + } + return ( + tierColorMap[creds?.tier_id || ''] || + 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400' + ) } - return ( - tierColorMap[creds?.tier_id || ''] || - 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400' - ) + + if (isGoogleOne.value) { + // Google One tier 样式 + const tierColorMap: Record = { + AI_PREMIUM: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + GOOGLE_ONE_BASIC: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + FREE: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', + GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', + GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' + } + return tierColorMap[creds?.tier_id || ''] || 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + } + + // AI Studio 默认样式:蓝色 + return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }) // 是否限流 diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index e743c1d2..8dfb9f38 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -568,6 +568,24 @@ const isGeminiCodeAssist = computed(() => { // Gemini 账户类型显示标签 const geminiTierLabel = computed(() => { if (!geminiTier.value) return null + + const creds = props.account.credentials as GeminiCredentials | undefined + const isGoogleOne = creds?.oauth_type === 'google_one' + + if (isGoogleOne) { + // Google One tier 标签 + const tierMap: Record = { + AI_PREMIUM: t('admin.accounts.tier.aiPremium'), + GOOGLE_ONE_STANDARD: t('admin.accounts.tier.standard'), + GOOGLE_ONE_BASIC: t('admin.accounts.tier.basic'), + FREE: t('admin.accounts.tier.free'), + GOOGLE_ONE_UNKNOWN: t('admin.accounts.tier.personal'), + GOOGLE_ONE_UNLIMITED: t('admin.accounts.tier.unlimited') + } + return tierMap[geminiTier.value] || t('admin.accounts.tier.personal') + } + + // Code Assist tier 标签 const tierMap: Record = { LEGACY: t('admin.accounts.tier.free'), PRO: t('admin.accounts.tier.pro'), @@ -578,6 +596,25 @@ const geminiTierLabel = computed(() => { // Gemini 账户类型徽章样式 const geminiTierClass = computed(() => { + if (!geminiTier.value) return '' + + const creds = props.account.credentials as GeminiCredentials | undefined + const isGoogleOne = creds?.oauth_type === 'google_one' + + if (isGoogleOne) { + // Google One tier 颜色 + const colorMap: Record = { + AI_PREMIUM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300', + GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300', + GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300', + FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', + GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', + GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300' + } + return colorMap[geminiTier.value] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' + } + + // Code Assist tier 颜色 switch (geminiTier.value) { case 'LEGACY': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 7cb54dc0..0f188e08 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -455,6 +455,52 @@
+ + + + +
-
- +
+ + +
+ - -
- {{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }} + +
+
+ + {{ t('admin.accounts.gemini.oauthType.customTitle') }} + + + {{ t('admin.accounts.gemini.oauthType.customDesc') }} + +
+ {{ t('admin.accounts.gemini.oauthType.customRequirement') }} +
+
+ + {{ t('admin.accounts.gemini.oauthType.badges.orgManaged') }} + + + {{ t('admin.accounts.gemini.oauthType.badges.adminRequired') }} + +
+
+ + {{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }} + + + +
+ {{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
@@ -1610,8 +1672,9 @@ const selectedErrorCodes = ref([]) const customErrorCodeInput = ref(null) const interceptWarmupRequests = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling -const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist') +const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one') const geminiAIStudioOAuthEnabled = ref(false) +const showAdvancedOAuth = ref(false) // Common models for whitelist - Anthropic const anthropicModels = [ @@ -1902,7 +1965,7 @@ watch( { immediate: true } ) -const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => { +const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => { if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) { appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured')) return diff --git a/frontend/src/composables/useGeminiOAuth.ts b/frontend/src/composables/useGeminiOAuth.ts index fb20cc2f..14920417 100644 --- a/frontend/src/composables/useGeminiOAuth.ts +++ b/frontend/src/composables/useGeminiOAuth.ts @@ -93,7 +93,13 @@ export function useGeminiOAuth() { const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any) return tokenInfo as GeminiTokenInfo } catch (err: any) { - error.value = err.response?.data?.detail || t('admin.accounts.oauth.gemini.failedToExchangeCode') + // Check for specific missing project_id error + const errorMessage = err.message || err.response?.data?.message || '' + if (errorMessage.includes('missing project_id')) { + error.value = t('admin.accounts.oauth.gemini.missingProjectId') + } else { + error.value = errorMessage || t('admin.accounts.oauth.gemini.failedToExchangeCode') + } appStore.showError(error.value) return null } finally { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ac958590..33789586 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1076,6 +1076,7 @@ export default { failedToGenerateUrl: 'Failed to generate Gemini auth URL', missingExchangeParams: 'Missing auth code, session ID, or state', failedToExchangeCode: 'Failed to exchange Gemini auth code', + missingProjectId: 'GCP Project ID retrieval failed: Your Google account is not linked to an active GCP project. Please activate GCP and bind a credit card in Google Cloud Console, or manually enter the Project ID during authorization.', modelPassthrough: 'Gemini Model Passthrough', modelPassthroughDesc: 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', @@ -1290,7 +1291,12 @@ export default { tier: { free: 'Free', pro: 'Pro', - ultra: 'Ultra' + ultra: 'Ultra', + aiPremium: 'AI Premium', + standard: 'Standard', + basic: 'Basic', + personal: 'Personal', + unlimited: 'Unlimited' }, ineligibleWarning: 'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index b4de5ada..45d1a9a8 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -996,7 +996,12 @@ export default { tier: { free: 'Free', pro: 'Pro', - ultra: 'Ultra' + ultra: 'Ultra', + aiPremium: 'AI Premium', + standard: '标准版', + basic: '基础版', + personal: '个人版', + unlimited: '无限制' }, ineligibleWarning: '该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。', @@ -1215,6 +1220,7 @@ export default { failedToGenerateUrl: '生成 Gemini 授权链接失败', missingExchangeParams: '缺少 code / session_id / state', failedToExchangeCode: 'Gemini 授权码兑换失败', + missingProjectId: 'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。', modelPassthrough: 'Gemini 直接转发模型', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', stateWarningTitle: '提示',