Files
sub2api/frontend/src/components/account/AccountQuotaInfo.vue
IanShaw027 e49281774d 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
2026-01-01 04:29:22 +08:00

195 lines
5.9 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>