## 功能概述 通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。 ## 后端改动 - 新增 Drive API 客户端 (drive_client.go) - 支持代理和指数退避重试 - 处理 403/429 错误 - 添加 Tier 推断逻辑 (inferGoogleOneTier) - 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED - 集成到 OAuth 流程 - ExchangeCode: 授权时自动获取 tier - RefreshAccountToken: Token 刷新时更新 tier (24小时缓存) - 新增管理 API 端点 - POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新) - POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新) ## 前端改动 - 更新 AccountQuotaInfo.vue - 添加 Google One tier 标签映射 - 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色) - 更新 AccountUsageCell.vue - 添加 Google One tier 显示逻辑 - 根据 oauth_type 区分显示方式 - 添加国际化翻译 (en.ts, zh.ts) - aiPremium, standard, basic, free, personal, unlimited ## Tier 推断规则 - >= 2TB: AI Premium - >= 200GB: Google One Standard - >= 100GB: Google One Basic - >= 15GB: Free - > 100TB: Unlimited (G Suite legacy) - 其他/失败: Unknown (显示为 Personal) ## 优雅降级 - Drive API 失败时使用 GOOGLE_ONE_UNKNOWN - 不阻断 OAuth 流程 - 24小时缓存避免频繁调用 ## 测试 - ✅ 后端编译成功 - ✅ 前端构建成功 - ✅ 所有代码符合现有规范
716 lines
24 KiB
Vue
716 lines
24 KiB
Vue
<template>
|
||
<div v-if="showUsageWindows">
|
||
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
|
||
<template
|
||
v-if="
|
||
account.platform === 'anthropic' &&
|
||
(account.type === 'oauth' || account.type === 'setup-token')
|
||
"
|
||
>
|
||
<!-- Loading state -->
|
||
<div v-if="loading" class="space-y-1.5">
|
||
<!-- OAuth: 3 rows, Setup Token: 1 row -->
|
||
<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-1.5 w-8 animate-pulse rounded-full 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>
|
||
</div>
|
||
<template v-if="account.type === 'oauth'">
|
||
<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-1.5 w-8 animate-pulse rounded-full 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>
|
||
</div>
|
||
<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-1.5 w-8 animate-pulse rounded-full 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>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Error state -->
|
||
<div v-else-if="error" class="text-xs text-red-500">
|
||
{{ error }}
|
||
</div>
|
||
|
||
<!-- Usage data -->
|
||
<div v-else-if="usageInfo" class="space-y-1">
|
||
<!-- 5h Window -->
|
||
<UsageProgressBar
|
||
v-if="usageInfo.five_hour"
|
||
label="5h"
|
||
:utilization="usageInfo.five_hour.utilization"
|
||
:resets-at="usageInfo.five_hour.resets_at"
|
||
:window-stats="usageInfo.five_hour.window_stats"
|
||
color="indigo"
|
||
/>
|
||
|
||
<!-- 7d Window (OAuth only) -->
|
||
<UsageProgressBar
|
||
v-if="usageInfo.seven_day"
|
||
label="7d"
|
||
:utilization="usageInfo.seven_day.utilization"
|
||
:resets-at="usageInfo.seven_day.resets_at"
|
||
color="emerald"
|
||
/>
|
||
|
||
<!-- 7d Sonnet Window (OAuth only) -->
|
||
<UsageProgressBar
|
||
v-if="usageInfo.seven_day_sonnet"
|
||
label="7d S"
|
||
:utilization="usageInfo.seven_day_sonnet.utilization"
|
||
:resets-at="usageInfo.seven_day_sonnet.resets_at"
|
||
color="purple"
|
||
/>
|
||
</div>
|
||
|
||
<!-- No data yet -->
|
||
<div v-else class="text-xs text-gray-400">-</div>
|
||
</template>
|
||
|
||
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
|
||
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
|
||
<div v-if="hasCodexUsage" class="space-y-1">
|
||
<!-- 5h Window -->
|
||
<UsageProgressBar
|
||
v-if="codex5hUsedPercent !== null"
|
||
label="5h"
|
||
:utilization="codex5hUsedPercent"
|
||
:resets-at="codex5hResetAt"
|
||
color="indigo"
|
||
/>
|
||
|
||
<!-- 7d Window -->
|
||
<UsageProgressBar
|
||
v-if="codex7dUsedPercent !== null"
|
||
label="7d"
|
||
:utilization="codex7dUsedPercent"
|
||
:resets-at="codex7dResetAt"
|
||
color="emerald"
|
||
/>
|
||
</div>
|
||
<div v-else class="text-xs text-gray-400">-</div>
|
||
</template>
|
||
|
||
<!-- Antigravity OAuth accounts: show quota from extra field -->
|
||
<template v-else-if="account.platform === 'antigravity' && account.type === 'oauth'">
|
||
<!-- 账户类型徽章 -->
|
||
<div v-if="antigravityTierLabel" class="mb-1 flex items-center gap-1">
|
||
<span
|
||
:class="[
|
||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||
antigravityTierClass
|
||
]"
|
||
>
|
||
{{ antigravityTierLabel }}
|
||
</span>
|
||
<!-- 不合格账户警告图标 -->
|
||
<span
|
||
v-if="hasIneligibleTiers"
|
||
class="group relative cursor-help"
|
||
>
|
||
<svg
|
||
class="h-3.5 w-3.5 text-red-500"
|
||
fill="currentColor"
|
||
viewBox="0 0 20 20"
|
||
>
|
||
<path
|
||
fill-rule="evenodd"
|
||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||
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"
|
||
>
|
||
{{ t('admin.accounts.ineligibleWarning') }}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="hasAntigravityQuota" class="space-y-1">
|
||
<!-- Gemini 3 Pro -->
|
||
<UsageProgressBar
|
||
v-if="antigravity3ProUsage !== null"
|
||
:label="t('admin.accounts.usageWindow.gemini3Pro')"
|
||
:utilization="antigravity3ProUsage.utilization"
|
||
:resets-at="antigravity3ProUsage.resetTime"
|
||
color="indigo"
|
||
/>
|
||
|
||
<!-- Gemini 3 Flash -->
|
||
<UsageProgressBar
|
||
v-if="antigravity3FlashUsage !== null"
|
||
:label="t('admin.accounts.usageWindow.gemini3Flash')"
|
||
:utilization="antigravity3FlashUsage.utilization"
|
||
:resets-at="antigravity3FlashUsage.resetTime"
|
||
color="emerald"
|
||
/>
|
||
|
||
<!-- Gemini 3 Image -->
|
||
<UsageProgressBar
|
||
v-if="antigravity3ImageUsage !== null"
|
||
:label="t('admin.accounts.usageWindow.gemini3Image')"
|
||
:utilization="antigravity3ImageUsage.utilization"
|
||
:resets-at="antigravity3ImageUsage.resetTime"
|
||
color="purple"
|
||
/>
|
||
|
||
<!-- Claude 4.5 -->
|
||
<UsageProgressBar
|
||
v-if="antigravityClaude45Usage !== null"
|
||
:label="t('admin.accounts.usageWindow.claude45')"
|
||
:utilization="antigravityClaude45Usage.utilization"
|
||
:resets-at="antigravityClaude45Usage.resetTime"
|
||
color="amber"
|
||
/>
|
||
</div>
|
||
<div v-else class="text-xs text-gray-400">-</div>
|
||
</template>
|
||
|
||
<!-- 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">
|
||
<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>
|
||
<div class="h-1.5 w-8 animate-pulse rounded-full 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>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="error" class="text-xs text-red-500">
|
||
{{ error }}
|
||
</div>
|
||
<div v-else-if="geminiUsageAvailable" class="space-y-1">
|
||
<UsageProgressBar
|
||
v-if="usageInfo?.gemini_pro_daily"
|
||
:label="t('admin.accounts.usageWindow.geminiProDaily')"
|
||
:utilization="usageInfo.gemini_pro_daily.utilization"
|
||
:resets-at="usageInfo.gemini_pro_daily.resets_at"
|
||
:window-stats="usageInfo.gemini_pro_daily.window_stats"
|
||
:stats-title="t('admin.accounts.usageWindow.statsTitleDaily')"
|
||
color="indigo"
|
||
/>
|
||
<UsageProgressBar
|
||
v-if="usageInfo?.gemini_flash_daily"
|
||
:label="t('admin.accounts.usageWindow.geminiFlashDaily')"
|
||
:utilization="usageInfo.gemini_flash_daily.utilization"
|
||
:resets-at="usageInfo.gemini_flash_daily.resets_at"
|
||
:window-stats="usageInfo.gemini_flash_daily.window_stats"
|
||
:stats-title="t('admin.accounts.usageWindow.statsTitleDaily')"
|
||
color="emerald"
|
||
/>
|
||
<p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic">
|
||
* {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Other accounts: no usage window -->
|
||
<template v-else>
|
||
<div class="text-xs text-gray-400">-</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Non-OAuth/Setup-Token accounts -->
|
||
<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">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { adminAPI } from '@/api/admin'
|
||
import type { Account, AccountUsageInfo, GeminiCredentials } from '@/types'
|
||
import UsageProgressBar from './UsageProgressBar.vue'
|
||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||
|
||
const props = defineProps<{
|
||
account: Account
|
||
}>()
|
||
|
||
const { t } = useI18n()
|
||
|
||
const loading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||
|
||
// Show usage windows for OAuth and Setup Token accounts
|
||
const showUsageWindows = computed(
|
||
() => props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||
)
|
||
|
||
const shouldFetchUsage = computed(() => {
|
||
if (props.account.platform === 'anthropic') {
|
||
return props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||
}
|
||
if (props.account.platform === 'gemini') {
|
||
return props.account.type === 'oauth'
|
||
}
|
||
return false
|
||
})
|
||
|
||
const geminiUsageAvailable = computed(() => {
|
||
return (
|
||
!!usageInfo.value?.gemini_pro_daily ||
|
||
!!usageInfo.value?.gemini_flash_daily
|
||
)
|
||
})
|
||
|
||
// OpenAI Codex usage computed properties
|
||
const hasCodexUsage = computed(() => {
|
||
const extra = props.account.extra
|
||
return (
|
||
extra &&
|
||
// Check for new canonical fields first
|
||
(extra.codex_5h_used_percent !== undefined ||
|
||
extra.codex_7d_used_percent !== undefined ||
|
||
// Fallback to legacy fields
|
||
extra.codex_primary_used_percent !== undefined ||
|
||
extra.codex_secondary_used_percent !== undefined)
|
||
)
|
||
})
|
||
|
||
// 5h window usage (prefer canonical field)
|
||
const codex5hUsedPercent = computed(() => {
|
||
const extra = props.account.extra
|
||
if (!extra) return null
|
||
|
||
// Prefer canonical field
|
||
if (extra.codex_5h_used_percent !== undefined) {
|
||
return extra.codex_5h_used_percent
|
||
}
|
||
|
||
// Fallback: detect from legacy fields using window_minutes
|
||
if (
|
||
extra.codex_primary_window_minutes !== undefined &&
|
||
extra.codex_primary_window_minutes <= 360
|
||
) {
|
||
return extra.codex_primary_used_percent ?? null
|
||
}
|
||
if (
|
||
extra.codex_secondary_window_minutes !== undefined &&
|
||
extra.codex_secondary_window_minutes <= 360
|
||
) {
|
||
return extra.codex_secondary_used_percent ?? null
|
||
}
|
||
|
||
// Legacy assumption: secondary = 5h (may be incorrect)
|
||
return extra.codex_secondary_used_percent ?? null
|
||
})
|
||
|
||
const codex5hResetAt = computed(() => {
|
||
const extra = props.account.extra
|
||
if (!extra) return null
|
||
|
||
// Prefer canonical field
|
||
if (extra.codex_5h_reset_after_seconds !== undefined) {
|
||
const resetTime = new Date(Date.now() + extra.codex_5h_reset_after_seconds * 1000)
|
||
return resetTime.toISOString()
|
||
}
|
||
|
||
// Fallback: detect from legacy fields using window_minutes
|
||
if (
|
||
extra.codex_primary_window_minutes !== undefined &&
|
||
extra.codex_primary_window_minutes <= 360
|
||
) {
|
||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||
return resetTime.toISOString()
|
||
}
|
||
}
|
||
if (
|
||
extra.codex_secondary_window_minutes !== undefined &&
|
||
extra.codex_secondary_window_minutes <= 360
|
||
) {
|
||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||
return resetTime.toISOString()
|
||
}
|
||
}
|
||
|
||
// Legacy assumption: secondary = 5h
|
||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||
return resetTime.toISOString()
|
||
}
|
||
|
||
return null
|
||
})
|
||
|
||
// 7d window usage (prefer canonical field)
|
||
const codex7dUsedPercent = computed(() => {
|
||
const extra = props.account.extra
|
||
if (!extra) return null
|
||
|
||
// Prefer canonical field
|
||
if (extra.codex_7d_used_percent !== undefined) {
|
||
return extra.codex_7d_used_percent
|
||
}
|
||
|
||
// Fallback: detect from legacy fields using window_minutes
|
||
if (
|
||
extra.codex_primary_window_minutes !== undefined &&
|
||
extra.codex_primary_window_minutes >= 10000
|
||
) {
|
||
return extra.codex_primary_used_percent ?? null
|
||
}
|
||
if (
|
||
extra.codex_secondary_window_minutes !== undefined &&
|
||
extra.codex_secondary_window_minutes >= 10000
|
||
) {
|
||
return extra.codex_secondary_used_percent ?? null
|
||
}
|
||
|
||
// Legacy assumption: primary = 7d (may be incorrect)
|
||
return extra.codex_primary_used_percent ?? null
|
||
})
|
||
|
||
const codex7dResetAt = computed(() => {
|
||
const extra = props.account.extra
|
||
if (!extra) return null
|
||
|
||
// Prefer canonical field
|
||
if (extra.codex_7d_reset_after_seconds !== undefined) {
|
||
const resetTime = new Date(Date.now() + extra.codex_7d_reset_after_seconds * 1000)
|
||
return resetTime.toISOString()
|
||
}
|
||
|
||
// Fallback: detect from legacy fields using window_minutes
|
||
if (
|
||
extra.codex_primary_window_minutes !== undefined &&
|
||
extra.codex_primary_window_minutes >= 10000
|
||
) {
|
||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||
return resetTime.toISOString()
|
||
}
|
||
}
|
||
if (
|
||
extra.codex_secondary_window_minutes !== undefined &&
|
||
extra.codex_secondary_window_minutes >= 10000
|
||
) {
|
||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||
return resetTime.toISOString()
|
||
}
|
||
}
|
||
|
||
// Legacy assumption: primary = 7d
|
||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||
return resetTime.toISOString()
|
||
}
|
||
|
||
return null
|
||
})
|
||
|
||
// Antigravity quota types
|
||
interface AntigravityModelQuota {
|
||
remaining: number // 剩余百分比 0-100
|
||
reset_time: string // ISO 8601 重置时间
|
||
}
|
||
|
||
interface AntigravityQuotaData {
|
||
[model: string]: AntigravityModelQuota
|
||
}
|
||
|
||
interface AntigravityUsageResult {
|
||
utilization: number
|
||
resetTime: string | null
|
||
}
|
||
|
||
// Antigravity quota computed properties
|
||
const hasAntigravityQuota = computed(() => {
|
||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||
return extra && typeof extra.quota === 'object' && extra.quota !== null
|
||
})
|
||
|
||
// 从配额数据中获取使用率(多模型取最低剩余 = 最高使用)
|
||
const getAntigravityUsage = (
|
||
modelNames: string[]
|
||
): AntigravityUsageResult | null => {
|
||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||
if (!extra || typeof extra.quota !== 'object' || extra.quota === null) return null
|
||
|
||
const quota = extra.quota as AntigravityQuotaData
|
||
|
||
let minRemaining = 100
|
||
let earliestReset: string | null = null
|
||
|
||
for (const model of modelNames) {
|
||
const modelQuota = quota[model]
|
||
if (!modelQuota) continue
|
||
|
||
if (modelQuota.remaining < minRemaining) {
|
||
minRemaining = modelQuota.remaining
|
||
}
|
||
if (modelQuota.reset_time) {
|
||
if (!earliestReset || modelQuota.reset_time < earliestReset) {
|
||
earliestReset = modelQuota.reset_time
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没有找到任何匹配的模型
|
||
if (minRemaining === 100 && earliestReset === null) {
|
||
// 检查是否至少有一个模型有数据
|
||
const hasAnyData = modelNames.some((m) => quota[m])
|
||
if (!hasAnyData) return null
|
||
}
|
||
|
||
return {
|
||
utilization: 100 - minRemaining,
|
||
resetTime: earliestReset
|
||
}
|
||
}
|
||
|
||
// Gemini 3 Pro: gemini-3-pro-low, gemini-3-pro-high, gemini-3-pro-preview
|
||
const antigravity3ProUsage = computed(() =>
|
||
getAntigravityUsage(['gemini-3-pro-low', 'gemini-3-pro-high', 'gemini-3-pro-preview'])
|
||
)
|
||
|
||
// Gemini 3 Flash: gemini-3-flash
|
||
const antigravity3FlashUsage = computed(() => getAntigravityUsage(['gemini-3-flash']))
|
||
|
||
// Gemini 3 Image: gemini-3-pro-image
|
||
const antigravity3ImageUsage = computed(() => getAntigravityUsage(['gemini-3-pro-image']))
|
||
|
||
// Claude 4.5: claude-sonnet-4-5, claude-opus-4-5-thinking
|
||
const antigravityClaude45Usage = computed(() =>
|
||
getAntigravityUsage(['claude-sonnet-4-5', 'claude-opus-4-5-thinking'])
|
||
)
|
||
|
||
// Antigravity 账户类型(从 load_code_assist 响应中提取)
|
||
const antigravityTier = computed(() => {
|
||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||
if (!extra) return null
|
||
|
||
const loadCodeAssist = extra.load_code_assist as Record<string, unknown> | undefined
|
||
if (!loadCodeAssist) return null
|
||
|
||
// 优先取 paidTier,否则取 currentTier
|
||
const paidTier = loadCodeAssist.paidTier as Record<string, unknown> | undefined
|
||
if (paidTier && typeof paidTier.id === 'string') {
|
||
return paidTier.id
|
||
}
|
||
|
||
const currentTier = loadCodeAssist.currentTier as Record<string, unknown> | undefined
|
||
if (currentTier && typeof currentTier.id === 'string') {
|
||
return currentTier.id
|
||
}
|
||
|
||
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 creds = props.account.credentials as GeminiCredentials | undefined
|
||
const isGoogleOne = creds?.oauth_type === 'google_one'
|
||
|
||
if (isGoogleOne) {
|
||
// Google One tier 标签
|
||
const tierMap: Record<string, string> = {
|
||
AI_PREMIUM: t('admin.accounts.tier.aiPremium'),
|
||
GOOGLE_ONE_STANDARD: t('admin.accounts.tier.standard'),
|
||
GOOGLE_ONE_BASIC: t('admin.accounts.tier.basic'),
|
||
FREE: t('admin.accounts.tier.free'),
|
||
GOOGLE_ONE_UNKNOWN: t('admin.accounts.tier.personal'),
|
||
GOOGLE_ONE_UNLIMITED: t('admin.accounts.tier.unlimited')
|
||
}
|
||
return tierMap[geminiTier.value] || t('admin.accounts.tier.personal')
|
||
}
|
||
|
||
// Code Assist tier 标签
|
||
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(() => {
|
||
if (!geminiTier.value) return ''
|
||
|
||
const creds = props.account.credentials as GeminiCredentials | undefined
|
||
const isGoogleOne = creds?.oauth_type === 'google_one'
|
||
|
||
if (isGoogleOne) {
|
||
// Google One tier 颜色
|
||
const colorMap: Record<string, string> = {
|
||
AI_PREMIUM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300',
|
||
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
|
||
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300',
|
||
FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
||
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
||
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300'
|
||
}
|
||
return colorMap[geminiTier.value] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||
}
|
||
|
||
// Code Assist tier 颜色
|
||
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) {
|
||
case 'free-tier':
|
||
return t('admin.accounts.tier.free')
|
||
case 'g1-pro-tier':
|
||
return t('admin.accounts.tier.pro')
|
||
case 'g1-ultra-tier':
|
||
return t('admin.accounts.tier.ultra')
|
||
default:
|
||
return null
|
||
}
|
||
})
|
||
|
||
// 账户类型徽章样式
|
||
const antigravityTierClass = computed(() => {
|
||
switch (antigravityTier.value) {
|
||
case 'free-tier':
|
||
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||
case 'g1-pro-tier':
|
||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||
case 'g1-ultra-tier':
|
||
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||
default:
|
||
return ''
|
||
}
|
||
})
|
||
|
||
// 检测账户是否有不合格状态(ineligibleTiers)
|
||
const hasIneligibleTiers = computed(() => {
|
||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||
if (!extra) return false
|
||
|
||
const loadCodeAssist = extra.load_code_assist as Record<string, unknown> | undefined
|
||
if (!loadCodeAssist) return false
|
||
|
||
const ineligibleTiers = loadCodeAssist.ineligibleTiers as unknown[] | undefined
|
||
return Array.isArray(ineligibleTiers) && ineligibleTiers.length > 0
|
||
})
|
||
|
||
const loadUsage = async () => {
|
||
if (!shouldFetchUsage.value) return
|
||
|
||
loading.value = true
|
||
error.value = null
|
||
|
||
try {
|
||
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
|
||
} catch (e: any) {
|
||
error.value = t('common.error')
|
||
console.error('Failed to load usage:', e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadUsage()
|
||
})
|
||
</script>
|