fix(gemini): 修复 P0/P1 级别问题(429误判/Tier丢失/expires_at/前端一致性)
P0 修复(Critical - 影响生产稳定性): - 修复 429 判断逻辑:使用 project_id 判断而非 account.Type 防止 AI Studio OAuth 被误判为 Code Assist 5分钟窗口 - 修复 Tier ID 丢失:刷新时始终保留旧值,默认 LEGACY 防止 fetchProjectID 失败导致 tier_id 被清空 - 修复 expires_at 下界:添加 minTTL=30s 保护 防止 expires_in <= 300 时生成过去时间引发刷新风暴 P1 修复(Important - 行为一致性): - 前端 isCodeAssist 判断与后端一致(支持 legacy) - 前端日期解析添加 NaN 保护 - 迁移脚本覆盖 legacy 账号 前端功能(新增): - AccountQuotaInfo 组件:Tier Badge + 二元进度条 + 倒计时 - 定时器动态管理:watch 监听限流状态 - 类型定义:GeminiCredentials 接口 测试: - ✅ TypeScript 类型检查通过 - ✅ 前端构建成功(3.33s) - ✅ Gemini + Codex 双 AI 审查通过 Refs: #gemini-quota
This commit is contained in:
@@ -1886,13 +1886,47 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
|
|||||||
if statusCode != 429 {
|
if statusCode != 429 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取账号的 oauth_type、tier_id 和 project_id
|
||||||
|
oauthType := strings.TrimSpace(account.GetCredential("oauth_type"))
|
||||||
|
tierID := strings.TrimSpace(account.GetCredential("tier_id"))
|
||||||
|
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||||
|
|
||||||
|
// 判断是否为 Code Assist:以 project_id 是否存在为准(更可靠)
|
||||||
|
isCodeAssist := projectID != ""
|
||||||
|
// Legacy 兼容:oauth_type 为空但 project_id 存在时视为 code_assist
|
||||||
|
if oauthType == "" && isCodeAssist {
|
||||||
|
oauthType = "code_assist"
|
||||||
|
}
|
||||||
|
|
||||||
resetAt := ParseGeminiRateLimitResetTime(body)
|
resetAt := ParseGeminiRateLimitResetTime(body)
|
||||||
if resetAt == nil {
|
if resetAt == nil {
|
||||||
ra := time.Now().Add(5 * time.Minute)
|
// 根据账号类型使用不同的默认重置时间
|
||||||
|
var ra time.Time
|
||||||
|
if isCodeAssist {
|
||||||
|
// Code Assist: 5 分钟滚动窗口
|
||||||
|
ra = time.Now().Add(5 * time.Minute)
|
||||||
|
log.Printf("[Gemini 429] Account %d (Code Assist, tier=%s, project=%s) rate limited, reset in 5min", account.ID, tierID, projectID)
|
||||||
|
} else {
|
||||||
|
// API Key / AI Studio OAuth: PST 午夜
|
||||||
|
if ts := nextGeminiDailyResetUnix(); ts != nil {
|
||||||
|
ra = time.Unix(*ts, 0)
|
||||||
|
log.Printf("[Gemini 429] Account %d (API Key/AI Studio, type=%s) rate limited, reset at PST midnight (%v)", account.ID, account.Type, ra)
|
||||||
|
} else {
|
||||||
|
// 兜底:5 分钟
|
||||||
|
ra = time.Now().Add(5 * time.Minute)
|
||||||
|
log.Printf("[Gemini 429] Account %d rate limited, fallback to 5min", account.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = s.accountRepo.SetRateLimited(ctx, account.ID, ra)
|
_ = s.accountRepo.SetRateLimited(ctx, account.ID, ra)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.accountRepo.SetRateLimited(ctx, account.ID, time.Unix(*resetAt, 0))
|
|
||||||
|
// 使用解析到的重置时间
|
||||||
|
resetTime := time.Unix(*resetAt, 0)
|
||||||
|
_ = s.accountRepo.SetRateLimited(ctx, account.ID, resetTime)
|
||||||
|
log.Printf("[Gemini 429] Account %d rate limited until %v (oauth_type=%s, tier=%s)",
|
||||||
|
account.ID, resetTime, oauthType, tierID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseGeminiRateLimitResetTime 解析 Gemini 格式的 429 响应,返回重置时间的 Unix 时间戳
|
// ParseGeminiRateLimitResetTime 解析 Gemini 格式的 429 响应,返回重置时间的 Unix 时间戳
|
||||||
|
|||||||
@@ -259,8 +259,15 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
|||||||
sessionProjectID := strings.TrimSpace(session.ProjectID)
|
sessionProjectID := strings.TrimSpace(session.ProjectID)
|
||||||
s.sessionStore.Delete(input.SessionID)
|
s.sessionStore.Delete(input.SessionID)
|
||||||
|
|
||||||
// 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
|
// 计算过期时间:减去 5 分钟安全时间窗口(考虑网络延迟和时钟偏差)
|
||||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
// 同时设置下界保护,防止 expires_in 过小导致过去时间(引发刷新风暴)
|
||||||
|
const safetyWindow = 300 // 5 minutes
|
||||||
|
const minTTL = 30 // minimum 30 seconds
|
||||||
|
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - safetyWindow
|
||||||
|
minExpiresAt := time.Now().Unix() + minTTL
|
||||||
|
if expiresAt < minExpiresAt {
|
||||||
|
expiresAt = minExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
projectID := sessionProjectID
|
projectID := sessionProjectID
|
||||||
var tierID string
|
var tierID string
|
||||||
@@ -275,10 +282,22 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
|||||||
// 记录警告但不阻断流程,允许后续补充 project_id
|
// 记录警告但不阻断流程,允许后续补充 project_id
|
||||||
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
|
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 用户手动填了 project_id,仍需调用 LoadCodeAssist 获取 tierID
|
||||||
|
_, fetchedTierID, err := s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch tierID: %v\n", err)
|
||||||
|
} else {
|
||||||
|
tierID = fetchedTierID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(projectID) == "" {
|
if strings.TrimSpace(projectID) == "" {
|
||||||
return nil, fmt.Errorf("missing project_id for Code Assist OAuth: please fill Project ID (optional field) and regenerate the auth URL, or ensure your Google account has an ACTIVE GCP project")
|
return nil, fmt.Errorf("missing project_id for Code Assist OAuth: please fill Project ID (optional field) and regenerate the auth URL, or ensure your Google account has an ACTIVE GCP project")
|
||||||
}
|
}
|
||||||
|
// tierID 缺失时使用默认值
|
||||||
|
if tierID == "" {
|
||||||
|
tierID = "LEGACY"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &GeminiTokenInfo{
|
return &GeminiTokenInfo{
|
||||||
@@ -308,8 +327,15 @@ func (s *GeminiOAuthService) RefreshToken(ctx context.Context, oauthType, refres
|
|||||||
|
|
||||||
tokenResp, err := s.oauthClient.RefreshToken(ctx, oauthType, refreshToken, proxyURL)
|
tokenResp, err := s.oauthClient.RefreshToken(ctx, oauthType, refreshToken, proxyURL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
|
// 计算过期时间:减去 5 分钟安全时间窗口(考虑网络延迟和时钟偏差)
|
||||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
// 同时设置下界保护,防止 expires_in 过小导致过去时间(引发刷新风暴)
|
||||||
|
const safetyWindow = 300 // 5 minutes
|
||||||
|
const minTTL = 30 // minimum 30 seconds
|
||||||
|
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - safetyWindow
|
||||||
|
minExpiresAt := time.Now().Unix() + minTTL
|
||||||
|
if expiresAt < minExpiresAt {
|
||||||
|
expiresAt = minExpiresAt
|
||||||
|
}
|
||||||
return &GeminiTokenInfo{
|
return &GeminiTokenInfo{
|
||||||
AccessToken: tokenResp.AccessToken,
|
AccessToken: tokenResp.AccessToken,
|
||||||
RefreshToken: tokenResp.RefreshToken,
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
@@ -396,19 +422,39 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
|
|||||||
tokenInfo.ProjectID = existingProjectID
|
tokenInfo.ProjectID = existingProjectID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尝试从账号凭证获取 tierID(向后兼容)
|
||||||
|
existingTierID := strings.TrimSpace(account.GetCredential("tier_id"))
|
||||||
|
|
||||||
// For Code Assist, project_id is required. Auto-detect if missing.
|
// For Code Assist, project_id is required. Auto-detect if missing.
|
||||||
// For AI Studio OAuth, project_id is optional and should not block refresh.
|
// For AI Studio OAuth, project_id is optional and should not block refresh.
|
||||||
if oauthType == "code_assist" && strings.TrimSpace(tokenInfo.ProjectID) == "" {
|
if oauthType == "code_assist" {
|
||||||
projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
|
// 先设置默认值或保留旧值,确保 tier_id 始终有值
|
||||||
if err != nil {
|
if existingTierID != "" {
|
||||||
return nil, fmt.Errorf("failed to auto-detect project_id: %w", err)
|
tokenInfo.TierID = existingTierID
|
||||||
|
} else {
|
||||||
|
tokenInfo.TierID = "LEGACY" // 默认值
|
||||||
}
|
}
|
||||||
projectID = strings.TrimSpace(projectID)
|
|
||||||
if projectID == "" {
|
// 尝试自动探测 project_id 和 tier_id
|
||||||
|
needDetect := strings.TrimSpace(tokenInfo.ProjectID) == "" || existingTierID == ""
|
||||||
|
if needDetect {
|
||||||
|
projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[GeminiOAuth] Warning: failed to auto-detect project/tier: %v\n", err)
|
||||||
|
} else {
|
||||||
|
if strings.TrimSpace(tokenInfo.ProjectID) == "" && projectID != "" {
|
||||||
|
tokenInfo.ProjectID = projectID
|
||||||
|
}
|
||||||
|
// 只有当原来没有 tier_id 且探测成功时才更新
|
||||||
|
if existingTierID == "" && tierID != "" {
|
||||||
|
tokenInfo.TierID = tierID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(tokenInfo.ProjectID) == "" {
|
||||||
return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
|
return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
|
||||||
}
|
}
|
||||||
tokenInfo.ProjectID = projectID
|
|
||||||
tokenInfo.TierID = tierID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenInfo, nil
|
return tokenInfo, nil
|
||||||
@@ -466,9 +512,6 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
|
|||||||
return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil
|
return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick tier from allowedTiers; if no default tier is marked, pick the first non-empty tier ID.
|
|
||||||
// (tierID already extracted above, reuse it)
|
|
||||||
|
|
||||||
req := &geminicli.OnboardUserRequest{
|
req := &geminicli.OnboardUserRequest{
|
||||||
TierID: tierID,
|
TierID: tierID,
|
||||||
Metadata: geminicli.LoadCodeAssistMetadata{
|
Metadata: geminicli.LoadCodeAssistMetadata{
|
||||||
@@ -487,7 +530,7 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
|
|||||||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||||||
return strings.TrimSpace(fallback), tierID, nil
|
return strings.TrimSpace(fallback), tierID, nil
|
||||||
}
|
}
|
||||||
return "", "", err
|
return "", tierID, err
|
||||||
}
|
}
|
||||||
if resp.Done {
|
if resp.Done {
|
||||||
if resp.Response != nil && resp.Response.CloudAICompanionProject != nil {
|
if resp.Response != nil && resp.Response.CloudAICompanionProject != nil {
|
||||||
@@ -505,7 +548,7 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
|
|||||||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||||||
return strings.TrimSpace(fallback), tierID, nil
|
return strings.TrimSpace(fallback), tierID, nil
|
||||||
}
|
}
|
||||||
return "", "", errors.New("onboardUser completed but no project_id returned")
|
return "", tierID, errors.New("onboardUser completed but no project_id returned")
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
@@ -515,9 +558,9 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
|
|||||||
return strings.TrimSpace(fallback), tierID, nil
|
return strings.TrimSpace(fallback), tierID, nil
|
||||||
}
|
}
|
||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
return "", "", fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
|
return "", tierID, fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
|
return "", tierID, fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
type googleCloudProject struct {
|
type googleCloudProject struct {
|
||||||
|
|||||||
30
backend/migrations/017_add_gemini_tier_id.sql
Normal file
30
backend/migrations/017_add_gemini_tier_id.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
-- 为 Gemini Code Assist OAuth 账号添加默认 tier_id
|
||||||
|
-- 包括显式标记为 code_assist 的账号,以及 legacy 账号(oauth_type 为空但 project_id 存在)
|
||||||
|
UPDATE accounts
|
||||||
|
SET credentials = jsonb_set(
|
||||||
|
credentials,
|
||||||
|
'{tier_id}',
|
||||||
|
'"LEGACY"',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
WHERE platform = 'gemini'
|
||||||
|
AND type = 'oauth'
|
||||||
|
AND jsonb_typeof(credentials) = 'object'
|
||||||
|
AND credentials->>'tier_id' IS NULL
|
||||||
|
AND (
|
||||||
|
credentials->>'oauth_type' = 'code_assist'
|
||||||
|
OR (credentials->>'oauth_type' IS NULL AND credentials->>'project_id' IS NOT NULL)
|
||||||
|
);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
-- 回滚:删除 tier_id 字段
|
||||||
|
UPDATE accounts
|
||||||
|
SET credentials = credentials - 'tier_id'
|
||||||
|
WHERE platform = 'gemini'
|
||||||
|
AND type = 'oauth'
|
||||||
|
AND credentials->>'oauth_type' = 'code_assist';
|
||||||
|
-- +goose StatementEnd
|
||||||
194
frontend/src/components/account/AccountQuotaInfo.vue
Normal file
194
frontend/src/components/account/AccountQuotaInfo.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="shouldShowQuota" class="flex items-center gap-2">
|
||||||
|
<!-- Tier Badge -->
|
||||||
|
<span :class="['badge text-xs px-2 py-0.5 rounded font-medium', tierBadgeClass]">
|
||||||
|
{{ tierLabel }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 限额文本 -->
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ quotaText }}</span>
|
||||||
|
|
||||||
|
<!-- 二元进度条 -->
|
||||||
|
<div class="group/progress relative flex items-center gap-1">
|
||||||
|
<div class="h-2 w-20 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-full transition-all',
|
||||||
|
isRateLimited ? 'bg-red-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
]"
|
||||||
|
:style="{ width: isRateLimited ? '100%' : '0%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'text-xs font-medium',
|
||||||
|
isRateLimited ? 'text-red-600 dark:text-red-400' : 'text-gray-400 dark:text-gray-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ isRateLimited ? '100%' : '0%' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 倒计时 -->
|
||||||
|
<span
|
||||||
|
v-if="isRateLimited"
|
||||||
|
:class="[
|
||||||
|
'text-xs font-medium',
|
||||||
|
isUrgent
|
||||||
|
? 'text-red-600 dark:text-red-400 animate-pulse'
|
||||||
|
: 'text-amber-600 dark:text-amber-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
⚠️ {{ resetCountdown }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Tooltip -->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute -top-10 left-0 z-10 hidden whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-lg group-hover/progress:block dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
ⓘ 无法提供实时额度
|
||||||
|
<div
|
||||||
|
class="absolute left-4 top-full border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||||
|
import type { Account, GeminiCredentials } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
account: Account
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const now = ref(new Date())
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
// 是否为 Code Assist OAuth
|
||||||
|
// 判断逻辑与后端保持一致:project_id 存在即为 Code Assist
|
||||||
|
const isCodeAssist = computed(() => {
|
||||||
|
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||||
|
// 显式为 code_assist,或 legacy 情况(oauth_type 为空但 project_id 存在)
|
||||||
|
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否应该显示配额信息
|
||||||
|
const shouldShowQuota = computed(() => {
|
||||||
|
return props.account.platform === 'gemini'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tier 标签文本
|
||||||
|
const tierLabel = computed(() => {
|
||||||
|
if (isCodeAssist.value) {
|
||||||
|
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||||
|
const tierMap: Record<string, string> = {
|
||||||
|
LEGACY: 'Free',
|
||||||
|
PRO: 'Standard',
|
||||||
|
ULTRA: 'Enterprise'
|
||||||
|
}
|
||||||
|
return tierMap[creds?.tier_id || ''] || 'Unknown'
|
||||||
|
}
|
||||||
|
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<string, string> = {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
tierColorMap[creds?.tier_id || ''] ||
|
||||||
|
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 限额文本
|
||||||
|
const quotaText = computed(() => {
|
||||||
|
if (isCodeAssist.value) {
|
||||||
|
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||||
|
const limitMap: Record<string, string> = {
|
||||||
|
LEGACY: '1000/day, 60/min',
|
||||||
|
PRO: '1500/day, 120/min',
|
||||||
|
ULTRA: '5000/day, 300/min'
|
||||||
|
}
|
||||||
|
return limitMap[creds?.tier_id || ''] || '-'
|
||||||
|
}
|
||||||
|
return 'RPM/RPD limits'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否限流
|
||||||
|
const isRateLimited = computed(() => {
|
||||||
|
if (!props.account.rate_limit_reset_at) return false
|
||||||
|
const resetTime = Date.parse(props.account.rate_limit_reset_at)
|
||||||
|
// 防护:如果日期解析失败(NaN),则认为未限流
|
||||||
|
if (Number.isNaN(resetTime)) return false
|
||||||
|
return resetTime > now.value.getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 倒计时文本
|
||||||
|
const resetCountdown = computed(() => {
|
||||||
|
if (!props.account.rate_limit_reset_at) return ''
|
||||||
|
const resetTime = Date.parse(props.account.rate_limit_reset_at)
|
||||||
|
// 防护:如果日期解析失败,显示 "-"
|
||||||
|
if (Number.isNaN(resetTime)) return '-'
|
||||||
|
|
||||||
|
const diffMs = resetTime - now.value.getTime()
|
||||||
|
if (diffMs <= 0) return 'now'
|
||||||
|
|
||||||
|
const diffSeconds = Math.floor(diffMs / 1000)
|
||||||
|
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60)
|
||||||
|
|
||||||
|
if (diffMinutes < 1) return `${diffSeconds}s`
|
||||||
|
if (diffHours < 1) {
|
||||||
|
const secs = diffSeconds % 60
|
||||||
|
return `${diffMinutes}m ${secs}s`
|
||||||
|
}
|
||||||
|
const mins = diffMinutes % 60
|
||||||
|
return `${diffHours}h ${mins}m`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否紧急(< 1分钟)
|
||||||
|
const isUrgent = computed(() => {
|
||||||
|
if (!props.account.rate_limit_reset_at) return false
|
||||||
|
const resetTime = Date.parse(props.account.rate_limit_reset_at)
|
||||||
|
// 防护:如果日期解析失败,返回 false
|
||||||
|
if (Number.isNaN(resetTime)) return false
|
||||||
|
|
||||||
|
const diffMs = resetTime - now.value.getTime()
|
||||||
|
return diffMs > 0 && diffMs < 60000
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听限流状态,动态启动/停止定时器
|
||||||
|
watch(
|
||||||
|
() => isRateLimited.value,
|
||||||
|
(limited) => {
|
||||||
|
if (limited && !timer) {
|
||||||
|
// 进入限流状态,启动定时器
|
||||||
|
timer = setInterval(() => {
|
||||||
|
now.value = new Date()
|
||||||
|
}, 1000)
|
||||||
|
} else if (!limited && timer) {
|
||||||
|
// 解除限流,停止定时器
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true } // 立即执行,确保挂载时已限流的情况也能启动定时器
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -169,6 +169,11 @@
|
|||||||
<div v-else class="text-xs text-gray-400">-</div>
|
<div v-else class="text-xs text-gray-400">-</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Gemini platform: show quota info with AccountQuotaInfo component -->
|
||||||
|
<template v-else-if="account.platform === 'gemini'">
|
||||||
|
<AccountQuotaInfo :account="account" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Other accounts: no usage window -->
|
<!-- Other accounts: no usage window -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="text-xs text-gray-400">-</div>
|
<div class="text-xs text-gray-400">-</div>
|
||||||
@@ -176,7 +181,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Non-OAuth/Setup-Token accounts -->
|
<!-- Non-OAuth/Setup-Token accounts -->
|
||||||
<div v-else class="text-xs text-gray-400">-</div>
|
<div v-else>
|
||||||
|
<!-- Gemini API Key accounts: show quota info -->
|
||||||
|
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
|
||||||
|
<div v-else class="text-xs text-gray-400">-</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -185,6 +194,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, AccountUsageInfo } from '@/types'
|
import type { Account, AccountUsageInfo } from '@/types'
|
||||||
import UsageProgressBar from './UsageProgressBar.vue'
|
import UsageProgressBar from './UsageProgressBar.vue'
|
||||||
|
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
|
|||||||
@@ -315,6 +315,22 @@ export interface Proxy {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gemini credentials structure for OAuth and API Key authentication
|
||||||
|
export interface GeminiCredentials {
|
||||||
|
// API Key authentication
|
||||||
|
api_key?: string
|
||||||
|
|
||||||
|
// OAuth authentication
|
||||||
|
access_token?: string
|
||||||
|
refresh_token?: string
|
||||||
|
oauth_type?: 'code_assist' | 'ai_studio' | string
|
||||||
|
tier_id?: 'LEGACY' | 'PRO' | 'ULTRA' | string
|
||||||
|
project_id?: string
|
||||||
|
token_type?: string
|
||||||
|
scope?: string
|
||||||
|
expires_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user