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:
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>
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user