refactor(frontend): 优化 Gemini 配额显示,参考 Antigravity 样式
- 简化标签:将 "RPD Pro/Flash" 改为 "Pro/Flash",避免文字截断 - 添加账号类型徽章(Free/Pro/Ultra),带颜色区分 - 添加帮助图标(?),悬停显示限流政策和官方文档链接 - 重构显示布局:账号类型 + 两行配额(Pro/Flash) - 移除冗余的 AccountQuotaInfo 组件调用
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -77,6 +77,7 @@ temp/
|
||||
*.temp
|
||||
*.log
|
||||
*.bak
|
||||
.cache/
|
||||
|
||||
# ===================
|
||||
# 构建产物
|
||||
|
||||
@@ -202,22 +202,25 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
|
||||
|
||||
func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
|
||||
now := time.Now()
|
||||
start := geminiDailyWindowStart(now)
|
||||
|
||||
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
|
||||
}
|
||||
|
||||
usage := &UsageInfo{
|
||||
UpdatedAt: &now,
|
||||
}
|
||||
|
||||
if s.geminiQuotaService == nil || s.usageLogRepo == nil {
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
quota, ok := s.geminiQuotaService.QuotaForAccount(ctx, account)
|
||||
if !ok {
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
start := geminiDailyWindowStart(now)
|
||||
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
|
||||
}
|
||||
|
||||
totals := geminiAggregateUsage(stats)
|
||||
resetAt := geminiDailyResetTime(now)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -17,8 +18,18 @@ type RateLimitService struct {
|
||||
usageRepo UsageLogRepository
|
||||
cfg *config.Config
|
||||
geminiQuotaService *GeminiQuotaService
|
||||
usageCacheMu sync.Mutex
|
||||
usageCache map[int64]*geminiUsageCacheEntry
|
||||
}
|
||||
|
||||
type geminiUsageCacheEntry struct {
|
||||
windowStart time.Time
|
||||
cachedAt time.Time
|
||||
totals GeminiUsageTotals
|
||||
}
|
||||
|
||||
const geminiPrecheckCacheTTL = time.Minute
|
||||
|
||||
// NewRateLimitService 创建RateLimitService实例
|
||||
func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogRepository, cfg *config.Config, geminiQuotaService *GeminiQuotaService) *RateLimitService {
|
||||
return &RateLimitService{
|
||||
@@ -26,6 +37,7 @@ func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogReposi
|
||||
usageRepo: usageRepo,
|
||||
cfg: cfg,
|
||||
geminiQuotaService: geminiQuotaService,
|
||||
usageCache: make(map[int64]*geminiUsageCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +85,7 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
|
||||
if account == nil || !account.IsGeminiCodeAssist() || strings.TrimSpace(requestedModel) == "" {
|
||||
return true, nil
|
||||
}
|
||||
if s.usageRepo == nil {
|
||||
if s.usageRepo == nil || s.geminiQuotaService == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -95,11 +107,15 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
|
||||
|
||||
now := time.Now()
|
||||
start := geminiDailyWindowStart(now)
|
||||
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
||||
if err != nil {
|
||||
return true, err
|
||||
totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
|
||||
if !ok {
|
||||
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
totals = geminiAggregateUsage(stats)
|
||||
s.setGeminiUsageTotals(account.ID, start, now, totals)
|
||||
}
|
||||
totals := geminiAggregateUsage(stats)
|
||||
|
||||
var used int64
|
||||
switch geminiModelClassFromName(requestedModel) {
|
||||
@@ -121,6 +137,40 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *RateLimitService) getGeminiUsageTotals(accountID int64, windowStart, now time.Time) (GeminiUsageTotals, bool) {
|
||||
s.usageCacheMu.Lock()
|
||||
defer s.usageCacheMu.Unlock()
|
||||
|
||||
if s.usageCache == nil {
|
||||
return GeminiUsageTotals{}, false
|
||||
}
|
||||
|
||||
entry, ok := s.usageCache[accountID]
|
||||
if !ok || entry == nil {
|
||||
return GeminiUsageTotals{}, false
|
||||
}
|
||||
if !entry.windowStart.Equal(windowStart) {
|
||||
return GeminiUsageTotals{}, false
|
||||
}
|
||||
if now.Sub(entry.cachedAt) >= geminiPrecheckCacheTTL {
|
||||
return GeminiUsageTotals{}, false
|
||||
}
|
||||
return entry.totals, true
|
||||
}
|
||||
|
||||
func (s *RateLimitService) setGeminiUsageTotals(accountID int64, windowStart, now time.Time, totals GeminiUsageTotals) {
|
||||
s.usageCacheMu.Lock()
|
||||
defer s.usageCacheMu.Unlock()
|
||||
if s.usageCache == nil {
|
||||
s.usageCache = make(map[int64]*geminiUsageCacheEntry)
|
||||
}
|
||||
s.usageCache[accountID] = &geminiUsageCacheEntry{
|
||||
windowStart: windowStart,
|
||||
cachedAt: now,
|
||||
totals: totals,
|
||||
}
|
||||
}
|
||||
|
||||
// GeminiCooldown returns the fallback cooldown duration for Gemini 429s based on tier.
|
||||
func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account) time.Duration {
|
||||
if account == nil {
|
||||
|
||||
@@ -26,5 +26,10 @@ UPDATE accounts
|
||||
SET credentials = credentials - 'tier_id'
|
||||
WHERE platform = 'gemini'
|
||||
AND type = 'oauth'
|
||||
AND credentials->>'oauth_type' = 'code_assist';
|
||||
AND jsonb_typeof(credentials) = 'object'
|
||||
AND credentials->>'tier_id' = 'LEGACY'
|
||||
AND (
|
||||
credentials->>'oauth_type' = 'code_assist'
|
||||
OR (credentials->>'oauth_type' IS NULL AND credentials->>'project_id' IS NOT NULL)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
v-if="!isRateLimited"
|
||||
class="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
未限流
|
||||
{{ t('admin.accounts.gemini.rateLimit.ok') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
@@ -21,19 +21,22 @@
|
||||
: 'text-amber-600 dark:text-amber-400'
|
||||
]"
|
||||
>
|
||||
限流 {{ resetCountdown }}
|
||||
{{ t('admin.accounts.gemini.rateLimit.limited', { time: resetCountdown }) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account, GeminiCredentials } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const now = ref(new Date())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -98,7 +101,7 @@ const resetCountdown = computed(() => {
|
||||
if (Number.isNaN(resetTime)) return '-'
|
||||
|
||||
const diffMs = resetTime - now.value.getTime()
|
||||
if (diffMs <= 0) return 'now'
|
||||
if (diffMs <= 0) return t('admin.accounts.gemini.rateLimit.now')
|
||||
|
||||
const diffSeconds = Math.floor(diffMs / 1000)
|
||||
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||
|
||||
@@ -171,8 +171,50 @@
|
||||
|
||||
<!-- Gemini platform: show quota + local usage window -->
|
||||
<template v-else-if="account.platform === 'gemini'">
|
||||
<!-- 账户类型徽章 -->
|
||||
<div v-if="geminiTierLabel" class="mb-1 flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
geminiTierClass
|
||||
]"
|
||||
>
|
||||
{{ geminiTierLabel }}
|
||||
</span>
|
||||
<!-- 帮助图标 -->
|
||||
<span
|
||||
class="group relative cursor-help"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="pointer-events-none absolute left-0 top-full z-50 mt-1 w-80 whitespace-normal break-words rounded bg-gray-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
<div class="font-semibold mb-1">{{ t('admin.accounts.gemini.quotaPolicy.title') }}</div>
|
||||
<div class="mb-2 text-gray-300">{{ t('admin.accounts.gemini.quotaPolicy.note') }}</div>
|
||||
<div class="space-y-1">
|
||||
<div><strong>{{ geminiQuotaPolicyChannel }}:</strong></div>
|
||||
<div class="pl-2">• {{ geminiQuotaPolicyLimits }}</div>
|
||||
<div class="mt-2">
|
||||
<a :href="geminiQuotaPolicyDocsUrl" target="_blank" class="text-blue-400 hover:text-blue-300 underline">
|
||||
{{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<AccountQuotaInfo :account="account" />
|
||||
<div v-if="loading" class="space-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
@@ -227,7 +269,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageInfo } from '@/types'
|
||||
import type { Account, AccountUsageInfo, GeminiCredentials } from '@/types'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
|
||||
@@ -509,6 +551,71 @@ const antigravityTier = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
// Gemini 账户类型(从 credentials 中提取)
|
||||
const geminiTier = computed(() => {
|
||||
if (props.account.platform !== 'gemini') return null
|
||||
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||
return creds?.tier_id || null
|
||||
})
|
||||
|
||||
// Gemini 是否为 Code Assist OAuth
|
||||
const isGeminiCodeAssist = computed(() => {
|
||||
if (props.account.platform !== 'gemini') return false
|
||||
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
|
||||
})
|
||||
|
||||
// Gemini 账户类型显示标签
|
||||
const geminiTierLabel = computed(() => {
|
||||
if (!geminiTier.value) return null
|
||||
const tierMap: Record<string, string> = {
|
||||
LEGACY: t('admin.accounts.tier.free'),
|
||||
PRO: t('admin.accounts.tier.pro'),
|
||||
ULTRA: t('admin.accounts.tier.ultra')
|
||||
}
|
||||
return tierMap[geminiTier.value] || null
|
||||
})
|
||||
|
||||
// Gemini 账户类型徽章样式
|
||||
const geminiTierClass = computed(() => {
|
||||
switch (geminiTier.value) {
|
||||
case 'LEGACY':
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
case 'PRO':
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
case 'ULTRA':
|
||||
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Gemini 配额政策信息
|
||||
const geminiQuotaPolicyChannel = computed(() => {
|
||||
if (isGeminiCodeAssist.value) {
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.cli.channel')
|
||||
}
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel')
|
||||
})
|
||||
|
||||
const geminiQuotaPolicyLimits = computed(() => {
|
||||
if (isGeminiCodeAssist.value) {
|
||||
if (geminiTier.value === 'PRO' || geminiTier.value === 'ULTRA') {
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium')
|
||||
}
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree')
|
||||
}
|
||||
// AI Studio - 默认显示免费层限制
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree')
|
||||
})
|
||||
|
||||
const geminiQuotaPolicyDocsUrl = computed(() => {
|
||||
if (isGeminiCodeAssist.value) {
|
||||
return 'https://cloud.google.com/products/gemini/code-assist#pricing'
|
||||
}
|
||||
return 'https://ai.google.dev/pricing'
|
||||
})
|
||||
|
||||
// 账户类型显示标签
|
||||
const antigravityTierLabel = computed(() => {
|
||||
switch (antigravityTier.value) {
|
||||
|
||||
@@ -1208,6 +1208,11 @@ export default {
|
||||
limitsPaid: 'RPD unlimited; RPM 1000+ (project quota)'
|
||||
}
|
||||
}
|
||||
},
|
||||
rateLimit: {
|
||||
ok: 'Not rate limited',
|
||||
limited: 'Rate limited {time}',
|
||||
now: 'now'
|
||||
}
|
||||
},
|
||||
// Re-Auth Modal
|
||||
@@ -1275,8 +1280,8 @@ export default {
|
||||
usageWindow: {
|
||||
statsTitle: '5-Hour Window Usage Statistics',
|
||||
statsTitleDaily: 'Daily Usage Statistics',
|
||||
geminiProDaily: 'RPD Pro',
|
||||
geminiFlashDaily: 'RPD Flash',
|
||||
geminiProDaily: 'Pro',
|
||||
geminiFlashDaily: 'Flash',
|
||||
gemini3Pro: 'G3P',
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G3I',
|
||||
|
||||
@@ -986,8 +986,8 @@ export default {
|
||||
usageWindow: {
|
||||
statsTitle: '5小时窗口用量统计',
|
||||
statsTitleDaily: '每日用量统计',
|
||||
geminiProDaily: 'RPD Pro',
|
||||
geminiFlashDaily: 'RPD Flash',
|
||||
geminiProDaily: 'Pro',
|
||||
geminiFlashDaily: 'Flash',
|
||||
gemini3Pro: 'G3P',
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G3I',
|
||||
@@ -1342,6 +1342,11 @@ export default {
|
||||
limitsPaid: 'RPD 不限;RPM 1000+(项目配额)'
|
||||
}
|
||||
}
|
||||
},
|
||||
rateLimit: {
|
||||
ok: '未限流',
|
||||
limited: '限流 {time}',
|
||||
now: '现在'
|
||||
}
|
||||
},
|
||||
// Re-Auth Modal
|
||||
|
||||
Reference in New Issue
Block a user