refactor(frontend): 优化 Gemini 配额显示,参考 Antigravity 样式

- 简化标签:将 "RPD Pro/Flash" 改为 "Pro/Flash",避免文字截断
- 添加账号类型徽章(Free/Pro/Ultra),带颜色区分
- 添加帮助图标(?),悬停显示限流政策和官方文档链接
- 重构显示布局:账号类型 + 两行配额(Pro/Flash)
- 移除冗余的 AccountQuotaInfo 组件调用
This commit is contained in:
IanShaw027
2026-01-01 08:29:57 +08:00
parent 4a7e2a44d9
commit 8181746695
8 changed files with 201 additions and 22 deletions

1
.gitignore vendored
View File

@@ -77,6 +77,7 @@ temp/
*.temp *.temp
*.log *.log
*.bak *.bak
.cache/
# =================== # ===================
# 构建产物 # 构建产物

View File

@@ -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) { func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
now := time.Now() 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{ usage := &UsageInfo{
UpdatedAt: &now, UpdatedAt: &now,
} }
if s.geminiQuotaService == nil || s.usageLogRepo == nil {
return usage, nil
}
quota, ok := s.geminiQuotaService.QuotaForAccount(ctx, account) quota, ok := s.geminiQuotaService.QuotaForAccount(ctx, account)
if !ok { if !ok {
return usage, nil 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) totals := geminiAggregateUsage(stats)
resetAt := geminiDailyResetTime(now) resetAt := geminiDailyResetTime(now)

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
@@ -17,8 +18,18 @@ type RateLimitService struct {
usageRepo UsageLogRepository usageRepo UsageLogRepository
cfg *config.Config cfg *config.Config
geminiQuotaService *GeminiQuotaService 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实例 // NewRateLimitService 创建RateLimitService实例
func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogRepository, cfg *config.Config, geminiQuotaService *GeminiQuotaService) *RateLimitService { func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogRepository, cfg *config.Config, geminiQuotaService *GeminiQuotaService) *RateLimitService {
return &RateLimitService{ return &RateLimitService{
@@ -26,6 +37,7 @@ func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogReposi
usageRepo: usageRepo, usageRepo: usageRepo,
cfg: cfg, cfg: cfg,
geminiQuotaService: geminiQuotaService, 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) == "" { if account == nil || !account.IsGeminiCodeAssist() || strings.TrimSpace(requestedModel) == "" {
return true, nil return true, nil
} }
if s.usageRepo == nil { if s.usageRepo == nil || s.geminiQuotaService == nil {
return true, nil return true, nil
} }
@@ -95,11 +107,15 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
now := time.Now() now := time.Now()
start := geminiDailyWindowStart(now) start := geminiDailyWindowStart(now)
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID) totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
if err != nil { if !ok {
return true, err 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 var used int64
switch geminiModelClassFromName(requestedModel) { switch geminiModelClassFromName(requestedModel) {
@@ -121,6 +137,40 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
return true, nil 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. // GeminiCooldown returns the fallback cooldown duration for Gemini 429s based on tier.
func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account) time.Duration { func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account) time.Duration {
if account == nil { if account == nil {

View File

@@ -26,5 +26,10 @@ UPDATE accounts
SET credentials = credentials - 'tier_id' SET credentials = credentials - 'tier_id'
WHERE platform = 'gemini' WHERE platform = 'gemini'
AND type = 'oauth' 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 -- +goose StatementEnd

View File

@@ -10,7 +10,7 @@
v-if="!isRateLimited" v-if="!isRateLimited"
class="text-xs text-gray-400 dark:text-gray-500" class="text-xs text-gray-400 dark:text-gray-500"
> >
未限流 {{ t('admin.accounts.gemini.rateLimit.ok') }}
</span> </span>
<span <span
v-else v-else
@@ -21,19 +21,22 @@
: 'text-amber-600 dark:text-amber-400' : 'text-amber-600 dark:text-amber-400'
]" ]"
> >
限流 {{ resetCountdown }} {{ t('admin.accounts.gemini.rateLimit.limited', { time: resetCountdown }) }}
</span> </span>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue' import { computed, ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account, GeminiCredentials } from '@/types' import type { Account, GeminiCredentials } from '@/types'
const props = defineProps<{ const props = defineProps<{
account: Account account: Account
}>() }>()
const { t } = useI18n()
const now = ref(new Date()) const now = ref(new Date())
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null
@@ -98,7 +101,7 @@ const resetCountdown = computed(() => {
if (Number.isNaN(resetTime)) return '-' if (Number.isNaN(resetTime)) return '-'
const diffMs = resetTime - now.value.getTime() 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 diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60) const diffMinutes = Math.floor(diffSeconds / 60)

View File

@@ -171,8 +171,50 @@
<!-- Gemini platform: show quota + local usage window --> <!-- Gemini platform: show quota + local usage window -->
<template v-else-if="account.platform === 'gemini'"> <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"> <div class="space-y-1">
<AccountQuotaInfo :account="account" />
<div v-if="loading" class="space-y-1"> <div v-if="loading" class="space-y-1">
<div class="flex items-center gap-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> <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 { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' 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, GeminiCredentials } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue'
@@ -509,6 +551,71 @@ const antigravityTier = computed(() => {
return null 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(() => { const antigravityTierLabel = computed(() => {
switch (antigravityTier.value) { switch (antigravityTier.value) {

View File

@@ -1208,6 +1208,11 @@ export default {
limitsPaid: 'RPD unlimited; RPM 1000+ (project quota)' limitsPaid: 'RPD unlimited; RPM 1000+ (project quota)'
} }
} }
},
rateLimit: {
ok: 'Not rate limited',
limited: 'Rate limited {time}',
now: 'now'
} }
}, },
// Re-Auth Modal // Re-Auth Modal
@@ -1275,8 +1280,8 @@ export default {
usageWindow: { usageWindow: {
statsTitle: '5-Hour Window Usage Statistics', statsTitle: '5-Hour Window Usage Statistics',
statsTitleDaily: 'Daily Usage Statistics', statsTitleDaily: 'Daily Usage Statistics',
geminiProDaily: 'RPD Pro', geminiProDaily: 'Pro',
geminiFlashDaily: 'RPD Flash', geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P', gemini3Pro: 'G3P',
gemini3Flash: 'G3F', gemini3Flash: 'G3F',
gemini3Image: 'G3I', gemini3Image: 'G3I',

View File

@@ -986,8 +986,8 @@ export default {
usageWindow: { usageWindow: {
statsTitle: '5小时窗口用量统计', statsTitle: '5小时窗口用量统计',
statsTitleDaily: '每日用量统计', statsTitleDaily: '每日用量统计',
geminiProDaily: 'RPD Pro', geminiProDaily: 'Pro',
geminiFlashDaily: 'RPD Flash', geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P', gemini3Pro: 'G3P',
gemini3Flash: 'G3F', gemini3Flash: 'G3F',
gemini3Image: 'G3I', gemini3Image: 'G3I',
@@ -1342,6 +1342,11 @@ export default {
limitsPaid: 'RPD 不限RPM 1000+(项目配额)' limitsPaid: 'RPD 不限RPM 1000+(项目配额)'
} }
} }
},
rateLimit: {
ok: '未限流',
limited: '限流 {time}',
now: '现在'
} }
}, },
// Re-Auth Modal // Re-Auth Modal