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:
IanShaw027
2025-12-31 23:57:01 +08:00
parent 34c102045a
commit e49281774d
6 changed files with 349 additions and 22 deletions

View 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>

View File

@@ -169,6 +169,11 @@
<div v-else class="text-xs text-gray-400">-</div>
</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 -->
<template v-else>
<div class="text-xs text-gray-400">-</div>
@@ -176,7 +181,11 @@
</div>
<!-- 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>
<script setup lang="ts">
@@ -185,6 +194,7 @@ import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
const props = defineProps<{
account: Account