mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Unify quota snapshots and oauth refresh handling
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import client from '../client'
|
||||
import type { EndpointAPIKey, AllowedModels } from './types'
|
||||
import type { QuotaStatusSnapshot } from './types'
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { EndpointAPIKey, AllowedModels }
|
||||
@@ -212,9 +213,18 @@ export interface RefreshQuotaResult {
|
||||
results: Array<{
|
||||
key_id: string
|
||||
key_name: string
|
||||
status: 'success' | 'no_metadata' | 'error'
|
||||
// Codex: 额度字段为扁平结构;Antigravity: 返回 { antigravity: { quota_by_model: ... } }
|
||||
status:
|
||||
| 'success'
|
||||
| 'no_metadata'
|
||||
| 'quota_exhausted'
|
||||
| 'workspace_deactivated'
|
||||
| 'auth_invalid'
|
||||
| 'forbidden'
|
||||
| 'banned'
|
||||
| 'error'
|
||||
// provider 级 bucket 数据;前端应按当前 provider_type 包装回 upstream_metadata.<provider_type>
|
||||
metadata?: Record<string, unknown>
|
||||
quota_snapshot?: QuotaStatusSnapshot
|
||||
message?: string
|
||||
status_code?: number
|
||||
}>
|
||||
|
||||
@@ -143,7 +143,7 @@ export interface PoolKeyDetail {
|
||||
model_include_patterns?: string[] | null
|
||||
model_exclude_patterns?: string[] | null
|
||||
proxy?: ProxyConfig | null
|
||||
account_quota: string | null
|
||||
account_quota: string | null // compatibility only; UI should prefer status_snapshot.quota
|
||||
cooldown_reason: string | null
|
||||
cooldown_ttl_seconds: number | null
|
||||
cost_window_usage: number
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './api-format'
|
||||
export * from './provider'
|
||||
export * from './model'
|
||||
export * from './routing'
|
||||
export * from './statusSnapshot'
|
||||
|
||||
@@ -18,15 +18,45 @@ export interface AccountStatusSnapshot {
|
||||
recoverable?: boolean
|
||||
}
|
||||
|
||||
export interface QuotaWindowSnapshot {
|
||||
code: string
|
||||
label?: string | null
|
||||
scope?: 'account' | 'workspace' | 'model' | string
|
||||
unit?: 'percent' | 'count' | 'usd' | 'tokens' | string
|
||||
model?: string | null
|
||||
used_ratio?: number | null
|
||||
remaining_ratio?: number | null
|
||||
used_value?: number | null
|
||||
remaining_value?: number | null
|
||||
limit_value?: number | null
|
||||
reset_at?: number | null
|
||||
reset_seconds?: number | null
|
||||
window_minutes?: number | null
|
||||
is_exhausted?: boolean | null
|
||||
}
|
||||
|
||||
export interface QuotaCreditsSnapshot {
|
||||
has_credits?: boolean | null
|
||||
balance?: number | null
|
||||
unlimited?: boolean | null
|
||||
}
|
||||
|
||||
export interface QuotaStatusSnapshot {
|
||||
code: 'unknown' | 'ok' | 'exhausted'
|
||||
version?: number | null
|
||||
provider_type?: string | null
|
||||
code: 'unknown' | 'ok' | 'exhausted' | 'cooldown' | 'forbidden' | 'banned' | string
|
||||
label?: string | null
|
||||
reason?: string | null
|
||||
freshness?: 'fresh' | 'stale' | 'unknown' | 'error' | string | null
|
||||
source?: string | null
|
||||
observed_at?: number | null
|
||||
exhausted: boolean
|
||||
usage_ratio?: number | null
|
||||
updated_at?: number | null
|
||||
reset_seconds?: number | null
|
||||
plan_type?: string | null
|
||||
credits?: QuotaCreditsSnapshot | null
|
||||
windows?: QuotaWindowSnapshot[] | null
|
||||
}
|
||||
|
||||
export interface ProviderKeyStatusSnapshot {
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
||||
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
||||
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
|
||||
<span v-if="getQuotaText(key)">{{ shortenQuota(getQuotaText(key) || '') }}</span>
|
||||
<span v-if="key.proxy?.node_id">独立代理</span>
|
||||
<span
|
||||
v-if="key.last_used_at"
|
||||
@@ -300,6 +300,7 @@ import {
|
||||
getOAuthStatusDisplay,
|
||||
getOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
import { getQuotaDisplayText } from '@/utils/providerKeyQuota'
|
||||
|
||||
type QuickSelectorValue =
|
||||
| 'banned'
|
||||
@@ -488,6 +489,10 @@ function formatRelativeTime(value: string): string {
|
||||
return `${Math.floor(diff / 86_400_000)}天前`
|
||||
}
|
||||
|
||||
function getQuotaText(key: PoolKeyDetail): string | null {
|
||||
return getQuotaDisplayText(key, props.providerType)
|
||||
}
|
||||
|
||||
function shortenQuota(raw: string): string {
|
||||
return raw.split('|').map((segment) => {
|
||||
let value = segment.trim()
|
||||
|
||||
@@ -112,12 +112,14 @@ import {
|
||||
} from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { testModel } from '@/api/endpoints/providers'
|
||||
import type { UpstreamMetadata, QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
metadata: Record<string, unknown> | null
|
||||
metadata: UpstreamMetadata | null
|
||||
quotaSnapshot?: QuotaStatusSnapshot | null
|
||||
keyName: string
|
||||
providerId?: string
|
||||
keyId?: string
|
||||
@@ -138,14 +140,85 @@ interface QuotaItem {
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const testingModel = ref<string | null>(null)
|
||||
|
||||
function getQuotaSnapshotUpdatedAt(quota: QuotaStatusSnapshot | null | undefined): number | undefined {
|
||||
const updatedAt = quota?.updated_at ?? quota?.observed_at
|
||||
return typeof updatedAt === 'number' ? updatedAt : undefined
|
||||
}
|
||||
|
||||
function getQuotaWindowLiveResetSeconds(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
window: QuotaWindowSnapshot | null | undefined,
|
||||
): number | null {
|
||||
if (!window) return null
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (typeof window.reset_at === 'number') {
|
||||
return Math.max(window.reset_at - now, 0)
|
||||
}
|
||||
|
||||
if (typeof window.reset_seconds === 'number') {
|
||||
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||||
const elapsed = typeof updatedAt === 'number' ? Math.max(now - updatedAt, 0) : 0
|
||||
return Math.max(window.reset_seconds - elapsed, 0)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefined): QuotaItem[] {
|
||||
if (!quota) return []
|
||||
|
||||
const providerType = String(quota.provider_type || '').trim().toLowerCase()
|
||||
if (providerType && providerType !== 'antigravity') return []
|
||||
|
||||
const windows = Array.isArray(quota.windows)
|
||||
? quota.windows.filter(window => String(window?.scope || '').trim().toLowerCase() === 'model')
|
||||
: []
|
||||
if (windows.length === 0) return []
|
||||
|
||||
const items = windows
|
||||
.map((window) => {
|
||||
const model = String(window.model || window.label || window.code || '').trim()
|
||||
if (!model) return null
|
||||
|
||||
const usedPercent =
|
||||
typeof window.used_ratio === 'number'
|
||||
? Math.max(Math.min(window.used_ratio * 100, 100), 0)
|
||||
: typeof window.remaining_ratio === 'number'
|
||||
? Math.max(Math.min((1 - window.remaining_ratio) * 100, 100), 0)
|
||||
: null
|
||||
if (usedPercent == null) return null
|
||||
|
||||
const remainingPercent =
|
||||
typeof window.remaining_ratio === 'number'
|
||||
? Math.max(Math.min(window.remaining_ratio * 100, 100), 0)
|
||||
: Math.max(100 - usedPercent, 0)
|
||||
|
||||
return {
|
||||
model,
|
||||
label: String(window.label || window.model || model),
|
||||
usedPercent,
|
||||
remainingPercent,
|
||||
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
|
||||
} satisfies QuotaItem
|
||||
})
|
||||
.filter((item): item is QuotaItem => item !== null)
|
||||
|
||||
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
|
||||
return items
|
||||
}
|
||||
|
||||
const items = computed<QuotaItem[]>(() => {
|
||||
const snapshotItems = buildItemsFromQuotaSnapshot(props.quotaSnapshot)
|
||||
if (snapshotItems.length > 0) return snapshotItems
|
||||
|
||||
const antigravity = props.metadata?.antigravity
|
||||
if (!antigravity || typeof antigravity !== 'object') return []
|
||||
const quotaByModel = (antigravity as Record<string, unknown>).quota_by_model
|
||||
const quotaByModel = antigravity.quota_by_model
|
||||
if (!quotaByModel || typeof quotaByModel !== 'object') return []
|
||||
|
||||
const result: QuotaItem[] = []
|
||||
for (const [model, rawInfo] of Object.entries(quotaByModel as Record<string, unknown>)) {
|
||||
for (const [model, rawInfo] of Object.entries(quotaByModel)) {
|
||||
if (!model) continue
|
||||
const info = (rawInfo || {}) as Record<string, unknown>
|
||||
|
||||
|
||||
@@ -315,12 +315,12 @@
|
||||
</Badge>
|
||||
<!-- Kiro 订阅类型标签 -->
|
||||
<Badge
|
||||
v-if="provider.provider_type === 'kiro' && key.upstream_metadata?.kiro?.subscription_title"
|
||||
v-if="provider.provider_type === 'kiro' && getKiroSubscriptionTitle(key)"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
:class="getOAuthPlanTypeClass(formatKiroSubscription(key.upstream_metadata?.kiro?.subscription_title))"
|
||||
:class="getOAuthPlanTypeClass(formatKiroSubscription(getKiroSubscriptionTitle(key)))"
|
||||
>
|
||||
{{ formatKiroSubscription(key.upstream_metadata?.kiro?.subscription_title) }}
|
||||
{{ formatKiroSubscription(getKiroSubscriptionTitle(key)) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -403,7 +403,7 @@
|
||||
</template>
|
||||
<!-- Antigravity 账号未激活提示 -->
|
||||
<span
|
||||
v-if="provider.provider_type === 'antigravity' && key.is_active && isOAuthManagedCredential(key) && (!key.upstream_metadata || !hasAntigravityQuotaData(key.upstream_metadata))"
|
||||
v-if="provider.provider_type === 'antigravity' && key.is_active && isOAuthManagedCredential(key) && !hasAntigravityQuotaDisplayData(key)"
|
||||
class="text-[10px] text-orange-500 dark:text-orange-400"
|
||||
title="该账号尚未完成 Gemini Code Assist 激活,无法获取配额和使用模型"
|
||||
>
|
||||
@@ -550,7 +550,7 @@
|
||||
</div>
|
||||
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
|
||||
<div
|
||||
v-if="key.upstream_metadata && hasCodexQuotaData(key.upstream_metadata)"
|
||||
v-if="hasCodexQuotaDisplayData(key)"
|
||||
class="mt-2 p-2 bg-muted/30 rounded-md"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
@@ -561,82 +561,94 @@
|
||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="key.upstream_metadata.codex?.updated_at"
|
||||
v-if="getCodexQuotaDisplay(key)?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatCodexUpdatedAt(key.upstream_metadata.codex.updated_at) }}
|
||||
{{ formatCodexUpdatedAt(getCodexQuotaDisplay(key)?.updated_at || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="getCodexCreditsSummary(getCodexQuotaDisplay(key))"
|
||||
class="flex items-center justify-between text-[10px] mb-2"
|
||||
>
|
||||
<span class="text-muted-foreground">积分</span>
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="getCodexQuotaDisplay(key)?.has_credits === false ? 'text-red-600 dark:text-red-400' : 'text-foreground/80'"
|
||||
>
|
||||
{{ getCodexCreditsSummary(getCodexQuotaDisplay(key)) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
||||
<div
|
||||
class="grid gap-3"
|
||||
:class="isCodexTeamPlan(key) ? 'grid-cols-2' : 'grid-cols-1'"
|
||||
>
|
||||
<!-- 周限额 -->
|
||||
<div v-if="key.upstream_metadata.codex?.primary_used_percent !== undefined">
|
||||
<div v-if="getCodexQuotaDisplay(key)?.primary_used_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">周限额</span>
|
||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.codex.primary_used_percent)">
|
||||
{{ (100 - key.upstream_metadata.codex.primary_used_percent).toFixed(1) }}%
|
||||
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.primary_used_percent || 0)">
|
||||
{{ (100 - (getCodexQuotaDisplay(key)?.primary_used_percent || 0)).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.codex.primary_used_percent)"
|
||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.codex.primary_used_percent, 0)}%` }"
|
||||
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.primary_used_percent || 0)"
|
||||
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.primary_used_percent || 0), 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="(key.upstream_metadata.codex.primary_reset_at || key.upstream_metadata.codex.primary_reset_seconds) && shouldStartCodexResetCountdown(key.upstream_metadata.codex.primary_used_percent)"
|
||||
v-if="(getCodexQuotaDisplay(key)?.primary_reset_at || getCodexQuotaDisplay(key)?.primary_reset_seconds) && shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.primary_used_percent || 0)"
|
||||
class="text-[9px] mt-0.5 tabular-nums"
|
||||
:class="getResetCountdownClass(
|
||||
key.upstream_metadata.codex.primary_reset_at,
|
||||
key.upstream_metadata.codex.primary_reset_seconds,
|
||||
key.upstream_metadata.codex.updated_at,
|
||||
key.upstream_metadata.codex.primary_used_percent
|
||||
getCodexQuotaDisplay(key)?.primary_reset_at,
|
||||
getCodexQuotaDisplay(key)?.primary_reset_seconds,
|
||||
getCodexQuotaDisplay(key)?.updated_at,
|
||||
getCodexQuotaDisplay(key)?.primary_used_percent
|
||||
)"
|
||||
>
|
||||
{{ getResetCountdownText(
|
||||
key.upstream_metadata.codex.primary_reset_at,
|
||||
key.upstream_metadata.codex.primary_reset_seconds,
|
||||
key.upstream_metadata.codex.updated_at,
|
||||
key.upstream_metadata.codex.primary_used_percent
|
||||
getCodexQuotaDisplay(key)?.primary_reset_at,
|
||||
getCodexQuotaDisplay(key)?.primary_reset_seconds,
|
||||
getCodexQuotaDisplay(key)?.updated_at,
|
||||
getCodexQuotaDisplay(key)?.primary_used_percent
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 5H限额(仅 Team/Plus/Enterprise 显示) -->
|
||||
<div v-if="isCodexTeamPlan(key) && key.upstream_metadata.codex?.secondary_used_percent !== undefined">
|
||||
<div v-if="isCodexTeamPlan(key) && getCodexQuotaDisplay(key)?.secondary_used_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">5H限额</span>
|
||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.codex.secondary_used_percent)">
|
||||
{{ (100 - key.upstream_metadata.codex.secondary_used_percent).toFixed(1) }}%
|
||||
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)">
|
||||
{{ (100 - (getCodexQuotaDisplay(key)?.secondary_used_percent || 0)).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.codex.secondary_used_percent)"
|
||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.codex.secondary_used_percent, 0)}%` }"
|
||||
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)"
|
||||
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.secondary_used_percent || 0), 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldStartCodexResetCountdown(key.upstream_metadata.codex.secondary_used_percent)"
|
||||
v-if="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)"
|
||||
class="text-[9px] mt-0.5 tabular-nums"
|
||||
:class="getResetCountdownClass(
|
||||
key.upstream_metadata.codex.secondary_reset_at,
|
||||
key.upstream_metadata.codex.secondary_reset_seconds,
|
||||
key.upstream_metadata.codex.updated_at,
|
||||
key.upstream_metadata.codex.secondary_used_percent
|
||||
getCodexQuotaDisplay(key)?.secondary_reset_at,
|
||||
getCodexQuotaDisplay(key)?.secondary_reset_seconds,
|
||||
getCodexQuotaDisplay(key)?.updated_at,
|
||||
getCodexQuotaDisplay(key)?.secondary_used_percent
|
||||
)"
|
||||
>
|
||||
<template v-if="key.upstream_metadata.codex.secondary_reset_at || key.upstream_metadata.codex.secondary_reset_seconds">
|
||||
<template v-if="getCodexQuotaDisplay(key)?.secondary_reset_at || getCodexQuotaDisplay(key)?.secondary_reset_seconds">
|
||||
{{ getResetCountdownText(
|
||||
key.upstream_metadata.codex.secondary_reset_at,
|
||||
key.upstream_metadata.codex.secondary_reset_seconds,
|
||||
key.upstream_metadata.codex.updated_at,
|
||||
key.upstream_metadata.codex.secondary_used_percent
|
||||
getCodexQuotaDisplay(key)?.secondary_reset_at,
|
||||
getCodexQuotaDisplay(key)?.secondary_reset_seconds,
|
||||
getCodexQuotaDisplay(key)?.updated_at,
|
||||
getCodexQuotaDisplay(key)?.secondary_used_percent
|
||||
) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -648,13 +660,13 @@
|
||||
</div>
|
||||
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
||||
<div
|
||||
v-if="provider.provider_type === 'antigravity' && key.upstream_metadata && (hasAntigravityQuotaData(key.upstream_metadata) || isAntigravityForbidden(key.upstream_metadata))"
|
||||
v-if="provider.provider_type === 'antigravity' && (hasAntigravityQuotaDisplayData(key) || isAntigravityForbiddenKey(key))"
|
||||
class="mt-2 p-2 rounded-md"
|
||||
:class="isAntigravityForbidden(key.upstream_metadata) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||||
:class="isAntigravityForbiddenKey(key) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||||
>
|
||||
<!-- 封禁状态显示 -->
|
||||
<div
|
||||
v-if="isAntigravityForbidden(key.upstream_metadata)"
|
||||
v-if="isAntigravityForbiddenKey(key)"
|
||||
class="flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<ShieldX class="w-4 h-4 shrink-0" />
|
||||
@@ -663,18 +675,18 @@
|
||||
账户访问被禁止
|
||||
</div>
|
||||
<div
|
||||
v-if="key.upstream_metadata.antigravity?.forbidden_reason"
|
||||
v-if="getAntigravityForbiddenReason(key)"
|
||||
class="text-[10px] text-destructive/80 truncate"
|
||||
:title="key.upstream_metadata.antigravity?.forbidden_reason"
|
||||
:title="getAntigravityForbiddenReason(key)"
|
||||
>
|
||||
{{ key.upstream_metadata.antigravity?.forbidden_reason }}
|
||||
{{ getAntigravityForbiddenReason(key) }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="key.upstream_metadata.antigravity?.forbidden_at"
|
||||
v-if="getAntigravityForbiddenAt(key)"
|
||||
class="text-[9px] text-destructive/60 shrink-0"
|
||||
>
|
||||
{{ formatBanTimestamp(key.upstream_metadata.antigravity?.forbidden_at) }}
|
||||
{{ formatBanTimestamp(getAntigravityForbiddenAt(key)) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 正常配额显示 -->
|
||||
@@ -687,16 +699,16 @@
|
||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="key.upstream_metadata.antigravity?.updated_at"
|
||||
v-if="getAntigravityQuotaUpdatedAt(key)"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
||||
{{ formatAntigravityUpdatedAt(getAntigravityQuotaUpdatedAt(key) || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="group in getAntigravityQuotaSummary(key.upstream_metadata)"
|
||||
v-for="group in getAntigravityQuotaSummaryForKey(key)"
|
||||
:key="group.key"
|
||||
>
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
@@ -734,13 +746,13 @@
|
||||
</div>
|
||||
<!-- Kiro 上游额度信息(仅当有元数据时显示) -->
|
||||
<div
|
||||
v-if="provider.provider_type === 'kiro' && key.upstream_metadata && (hasKiroQuotaData(key.upstream_metadata) || isKiroBanned(key.upstream_metadata))"
|
||||
v-if="provider.provider_type === 'kiro' && (hasKiroQuotaDisplayData(key) || isKiroBannedKey(key))"
|
||||
class="mt-2 p-2 rounded-md"
|
||||
:class="isKiroBanned(key.upstream_metadata) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||||
:class="isKiroBannedKey(key) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||||
>
|
||||
<!-- 封禁状态显示 -->
|
||||
<div
|
||||
v-if="isKiroBanned(key.upstream_metadata)"
|
||||
v-if="isKiroBannedKey(key)"
|
||||
class="flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<ShieldX class="w-4 h-4 shrink-0" />
|
||||
@@ -749,18 +761,18 @@
|
||||
账户已封禁
|
||||
</div>
|
||||
<div
|
||||
v-if="key.upstream_metadata.kiro?.ban_reason"
|
||||
v-if="getKiroQuotaDisplay(key)?.ban_reason"
|
||||
class="text-[10px] text-destructive/80 truncate"
|
||||
:title="key.upstream_metadata.kiro?.ban_reason"
|
||||
:title="getKiroQuotaDisplay(key)?.ban_reason"
|
||||
>
|
||||
{{ key.upstream_metadata.kiro?.ban_reason }}
|
||||
{{ getKiroQuotaDisplay(key)?.ban_reason }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="key.upstream_metadata.kiro?.banned_at"
|
||||
v-if="getKiroQuotaDisplay(key)?.banned_at"
|
||||
class="text-[9px] text-destructive/60 shrink-0"
|
||||
>
|
||||
{{ formatBanTimestamp(key.upstream_metadata.kiro?.banned_at) }}
|
||||
{{ formatBanTimestamp(getKiroQuotaDisplay(key)?.banned_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 正常配额显示 -->
|
||||
@@ -773,10 +785,10 @@
|
||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="key.upstream_metadata.kiro?.updated_at"
|
||||
v-if="getKiroQuotaDisplay(key)?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatKiroUpdatedAt(key.upstream_metadata.kiro?.updated_at) }}
|
||||
{{ formatKiroUpdatedAt(getKiroQuotaDisplay(key)?.updated_at || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -786,24 +798,24 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">使用额度</span>
|
||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.kiro?.usage_percentage || 0)">
|
||||
{{ (100 - (key.upstream_metadata.kiro?.usage_percentage || 0)).toFixed(1) }}%
|
||||
<span :class="getQuotaRemainingClass(getKiroQuotaDisplay(key)?.usage_percentage || 0)">
|
||||
{{ (100 - (getKiroQuotaDisplay(key)?.usage_percentage || 0)).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.kiro?.usage_percentage || 0)"
|
||||
:style="{ width: `${Math.max(100 - (key.upstream_metadata.kiro?.usage_percentage || 0), 0)}%` }"
|
||||
:class="getQuotaRemainingBarColor(getKiroQuotaDisplay(key)?.usage_percentage || 0)"
|
||||
:style="{ width: `${Math.max(100 - (getKiroQuotaDisplay(key)?.usage_percentage || 0), 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
||||
<span>
|
||||
{{ formatKiroUsage(key.upstream_metadata.kiro?.current_usage) }} /
|
||||
{{ formatKiroUsage(key.upstream_metadata.kiro?.usage_limit) }}
|
||||
{{ formatKiroUsage(getKiroQuotaDisplay(key)?.current_usage) }} /
|
||||
{{ formatKiroUsage(getKiroQuotaDisplay(key)?.usage_limit) }}
|
||||
</span>
|
||||
<span v-if="key.upstream_metadata.kiro?.next_reset_at">
|
||||
{{ formatKiroResetTime(key.upstream_metadata.kiro?.next_reset_at) }}重置
|
||||
<span v-if="getKiroQuotaDisplay(key)?.next_reset_at">
|
||||
{{ formatKiroResetTime(getKiroQuotaDisplay(key)?.next_reset_at) }}重置
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1062,6 +1074,7 @@
|
||||
v-if="antigravityQuotaDialogKey"
|
||||
:open="antigravityQuotaDialogOpen"
|
||||
:metadata="antigravityQuotaDialogKey.upstream_metadata"
|
||||
:quota-snapshot="antigravityQuotaDialogKey.status_snapshot?.quota ?? null"
|
||||
:key-name="antigravityQuotaDialogKey.name || '未命名密钥'"
|
||||
:provider-id="providerId"
|
||||
:key-id="antigravityQuotaDialogKey.id"
|
||||
@@ -1152,7 +1165,15 @@ import {
|
||||
API_FORMAT_SHORT,
|
||||
sortApiFormats,
|
||||
} from '@/api/endpoints'
|
||||
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
||||
import type {
|
||||
UpstreamMetadata,
|
||||
AntigravityModelQuota,
|
||||
AntigravityUpstreamMetadata,
|
||||
CodexUpstreamMetadata,
|
||||
KiroUpstreamMetadata,
|
||||
QuotaStatusSnapshot,
|
||||
QuotaWindowSnapshot,
|
||||
} from '@/api/endpoints/types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||
@@ -1786,23 +1807,221 @@ const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
|
||||
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
|
||||
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
|
||||
|
||||
// 检查 Codex 是否有配额数据
|
||||
function hasCodexQuotaData(meta: UpstreamMetadata | null | undefined): boolean {
|
||||
if (!meta?.codex) return false
|
||||
// Codex 配额数据存储在 codex 子对象中
|
||||
return meta.codex.primary_used_percent !== undefined || meta.codex.secondary_used_percent !== undefined
|
||||
function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefined): boolean {
|
||||
if (!quota) return false
|
||||
return Boolean(
|
||||
(typeof quota.code === 'string' && quota.code.trim().toLowerCase() !== 'unknown')
|
||||
|| quota.updated_at != null
|
||||
|| quota.observed_at != null
|
||||
|| quota.usage_ratio != null
|
||||
|| (Array.isArray(quota.windows) && quota.windows.length > 0)
|
||||
|| quota.credits,
|
||||
)
|
||||
}
|
||||
|
||||
// 检查 Kiro 是否有配额数据
|
||||
function hasKiroQuotaData(meta: UpstreamMetadata | null | undefined): boolean {
|
||||
if (!meta?.kiro) return false
|
||||
return meta.kiro.usage_percentage !== undefined || meta.kiro.usage_limit !== undefined
|
||||
function getQuotaSnapshotForProvider(
|
||||
key: EndpointAPIKey,
|
||||
providerType: 'codex' | 'kiro' | 'antigravity' | 'gemini_cli',
|
||||
): QuotaStatusSnapshot | null {
|
||||
const quota = key.status_snapshot?.quota
|
||||
if (!quota) return null
|
||||
|
||||
const snapshotProviderType = quota.provider_type?.trim().toLowerCase()
|
||||
if (snapshotProviderType) {
|
||||
return snapshotProviderType === providerType ? quota : null
|
||||
}
|
||||
|
||||
return quotaSnapshotHasDisplayData(quota) ? quota : null
|
||||
}
|
||||
|
||||
// 检查 Kiro 账户是否被封禁
|
||||
function isKiroBanned(meta: UpstreamMetadata | null | undefined): boolean {
|
||||
if (!meta?.kiro) return false
|
||||
return meta.kiro.is_banned === true
|
||||
function getQuotaSnapshotUpdatedAt(quota: QuotaStatusSnapshot | null | undefined): number | undefined {
|
||||
const updatedAt = quota?.updated_at ?? quota?.observed_at
|
||||
return typeof updatedAt === 'number' ? updatedAt : undefined
|
||||
}
|
||||
|
||||
function getQuotaWindow(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
code: string,
|
||||
): QuotaWindowSnapshot | null {
|
||||
const windows = quota?.windows
|
||||
if (!Array.isArray(windows)) return null
|
||||
return windows.find(window => String(window?.code || '').trim().toLowerCase() === code.trim().toLowerCase()) ?? null
|
||||
}
|
||||
|
||||
function getQuotaWindowUsedPercent(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||||
if (!window) return undefined
|
||||
if (typeof window.used_ratio === 'number') {
|
||||
return Math.max(Math.min(window.used_ratio * 100, 100), 0)
|
||||
}
|
||||
if (typeof window.remaining_ratio === 'number') {
|
||||
return Math.max(Math.min((1 - window.remaining_ratio) * 100, 100), 0)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getQuotaWindowRemainingPercent(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||||
if (!window) return undefined
|
||||
if (typeof window.remaining_ratio === 'number') {
|
||||
return Math.max(Math.min(window.remaining_ratio * 100, 100), 0)
|
||||
}
|
||||
if (typeof window.used_ratio === 'number') {
|
||||
return Math.max(Math.min((1 - window.used_ratio) * 100, 100), 0)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getQuotaWindowResetAt(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||||
return typeof window?.reset_at === 'number' ? window.reset_at : undefined
|
||||
}
|
||||
|
||||
function getQuotaWindowResetSeconds(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||||
return typeof window?.reset_seconds === 'number' ? window.reset_seconds : undefined
|
||||
}
|
||||
|
||||
function getQuotaWindowByScope(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
scope: string,
|
||||
): QuotaWindowSnapshot[] {
|
||||
const windows = quota?.windows
|
||||
if (!Array.isArray(windows)) return []
|
||||
return windows.filter(window => String(window?.scope || '').trim().toLowerCase() === scope.trim().toLowerCase())
|
||||
}
|
||||
|
||||
function getQuotaWindowLiveResetSeconds(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
window: QuotaWindowSnapshot | null | undefined,
|
||||
): number | null {
|
||||
if (!window) return null
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (typeof window.reset_at === 'number') {
|
||||
return Math.max(window.reset_at - now, 0)
|
||||
}
|
||||
|
||||
if (typeof window.reset_seconds === 'number') {
|
||||
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||||
const elapsed = typeof updatedAt === 'number' ? Math.max(now - updatedAt, 0) : 0
|
||||
return Math.max(window.reset_seconds - elapsed, 0)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'codex')
|
||||
if (!quota) return null
|
||||
|
||||
const display: CodexUpstreamMetadata = {}
|
||||
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||||
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||||
if (quota.plan_type) display.plan_type = quota.plan_type
|
||||
|
||||
const primaryWindow = getQuotaWindow(quota, 'weekly')
|
||||
const primaryUsedPercent = getQuotaWindowUsedPercent(primaryWindow)
|
||||
if (primaryUsedPercent !== undefined) display.primary_used_percent = primaryUsedPercent
|
||||
const primaryResetAt = getQuotaWindowResetAt(primaryWindow)
|
||||
if (primaryResetAt !== undefined) display.primary_reset_at = primaryResetAt
|
||||
const primaryResetSeconds = getQuotaWindowResetSeconds(primaryWindow)
|
||||
if (primaryResetSeconds !== undefined) display.primary_reset_seconds = primaryResetSeconds
|
||||
if (typeof primaryWindow?.window_minutes === 'number') {
|
||||
display.primary_window_minutes = primaryWindow.window_minutes
|
||||
}
|
||||
|
||||
const secondaryWindow = getQuotaWindow(quota, '5h')
|
||||
const secondaryUsedPercent = getQuotaWindowUsedPercent(secondaryWindow)
|
||||
if (secondaryUsedPercent !== undefined) display.secondary_used_percent = secondaryUsedPercent
|
||||
const secondaryResetAt = getQuotaWindowResetAt(secondaryWindow)
|
||||
if (secondaryResetAt !== undefined) display.secondary_reset_at = secondaryResetAt
|
||||
const secondaryResetSeconds = getQuotaWindowResetSeconds(secondaryWindow)
|
||||
if (secondaryResetSeconds !== undefined) display.secondary_reset_seconds = secondaryResetSeconds
|
||||
if (typeof secondaryWindow?.window_minutes === 'number') {
|
||||
display.secondary_window_minutes = secondaryWindow.window_minutes
|
||||
}
|
||||
|
||||
if (typeof quota.credits?.has_credits === 'boolean') {
|
||||
display.has_credits = quota.credits.has_credits
|
||||
}
|
||||
if (typeof quota.credits?.balance === 'number') {
|
||||
display.credits_balance = quota.credits.balance
|
||||
}
|
||||
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
}
|
||||
|
||||
function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
const codex = getCodexQuotaDisplay(key)
|
||||
return !!codex && (
|
||||
codex.primary_used_percent !== undefined
|
||||
|| codex.secondary_used_percent !== undefined
|
||||
|| codex.has_credits !== undefined
|
||||
|| codex.credits_balance !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function getCodexCreditsSummary(codex: CodexUpstreamMetadata | null | undefined): string | null {
|
||||
if (!codex) return null
|
||||
if (codex.has_credits === true && typeof codex.credits_balance === 'number') {
|
||||
return `积分 ${codex.credits_balance.toFixed(2)}`
|
||||
}
|
||||
if (codex.has_credits === true) {
|
||||
return '有积分'
|
||||
}
|
||||
if (codex.has_credits === false) {
|
||||
return '无可用积分'
|
||||
}
|
||||
if (typeof codex.credits_balance === 'number') {
|
||||
return `积分 ${codex.credits_balance.toFixed(2)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getKiroQuotaDisplay(key: EndpointAPIKey): KiroUpstreamMetadata | null {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'kiro')
|
||||
if (!quota) return null
|
||||
|
||||
const display: KiroUpstreamMetadata = {}
|
||||
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||||
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||||
if (quota.plan_type) display.subscription_title = quota.plan_type
|
||||
|
||||
if (String(quota.code || '').trim().toLowerCase() === 'banned') {
|
||||
display.is_banned = true
|
||||
if (quota.reason) display.ban_reason = quota.reason
|
||||
if (updatedAt !== undefined) display.banned_at = updatedAt
|
||||
}
|
||||
|
||||
const usageWindow =
|
||||
getQuotaWindow(quota, 'usage')
|
||||
?? getQuotaWindowByScope(quota, 'account')[0]
|
||||
?? null
|
||||
if (usageWindow) {
|
||||
const usedPercent = getQuotaWindowUsedPercent(usageWindow)
|
||||
if (usedPercent !== undefined) display.usage_percentage = usedPercent
|
||||
if (typeof usageWindow.used_value === 'number') display.current_usage = usageWindow.used_value
|
||||
if (typeof usageWindow.limit_value === 'number') display.usage_limit = usageWindow.limit_value
|
||||
if (typeof usageWindow.remaining_value === 'number') display.remaining = usageWindow.remaining_value
|
||||
|
||||
const nextResetAt =
|
||||
getQuotaWindowResetAt(usageWindow)
|
||||
?? (() => {
|
||||
const resetSeconds = getQuotaWindowResetSeconds(usageWindow)
|
||||
if (updatedAt === undefined || resetSeconds === undefined) return undefined
|
||||
return updatedAt + resetSeconds
|
||||
})()
|
||||
if (nextResetAt !== undefined) display.next_reset_at = nextResetAt
|
||||
}
|
||||
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
}
|
||||
|
||||
function hasKiroQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
const kiro = getKiroQuotaDisplay(key)
|
||||
return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined)
|
||||
}
|
||||
|
||||
function isKiroBannedKey(key: EndpointAPIKey): boolean {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'kiro')
|
||||
return String(quota?.code || '').trim().toLowerCase() === 'banned'
|
||||
}
|
||||
|
||||
// 格式化封禁/禁止时间(后端返回秒级时间戳,Kiro/Antigravity 通用)
|
||||
@@ -1817,10 +2036,22 @@ function formatBanTimestamp(timestamp: number | undefined): string {
|
||||
})
|
||||
}
|
||||
|
||||
// 检查 Antigravity 账户是否被禁止访问
|
||||
function isAntigravityForbidden(meta: UpstreamMetadata | null | undefined): boolean {
|
||||
if (!meta?.antigravity) return false
|
||||
return meta.antigravity.is_forbidden === true
|
||||
function isAntigravityForbiddenKey(key: EndpointAPIKey): boolean {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||||
return String(quota?.code || '').trim().toLowerCase() === 'forbidden'
|
||||
}
|
||||
|
||||
function getAntigravityForbiddenReason(key: EndpointAPIKey): string | undefined {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||||
return quota?.reason || undefined
|
||||
}
|
||||
|
||||
function getAntigravityForbiddenAt(key: EndpointAPIKey): number | undefined {
|
||||
return getQuotaSnapshotUpdatedAt(getQuotaSnapshotForProvider(key, 'antigravity'))
|
||||
}
|
||||
|
||||
function getAntigravityQuotaUpdatedAt(key: EndpointAPIKey): number | undefined {
|
||||
return getQuotaSnapshotUpdatedAt(getQuotaSnapshotForProvider(key, 'antigravity'))
|
||||
}
|
||||
|
||||
// 格式化 Kiro 更新时间
|
||||
@@ -1877,6 +2108,10 @@ function formatKiroSubscription(title: string | undefined): string {
|
||||
return title
|
||||
}
|
||||
|
||||
function getKiroSubscriptionTitle(key: EndpointAPIKey): string | undefined {
|
||||
return getKiroQuotaDisplay(key)?.subscription_title
|
||||
}
|
||||
|
||||
function shouldAutoRefreshCodexQuota(): boolean {
|
||||
if (provider.value?.provider_type !== 'codex') return false
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -1886,13 +2121,12 @@ function shouldAutoRefreshCodexQuota(): boolean {
|
||||
|
||||
if (isTokenExpiringSoon(key, now)) return true
|
||||
|
||||
const meta: UpstreamMetadata | null | undefined = key.upstream_metadata
|
||||
// 只要有一个活跃 key 没有配额数据,就刷新一次
|
||||
if (!hasCodexQuotaData(meta)) {
|
||||
if (!hasCodexQuotaDisplayData(key)) {
|
||||
return true
|
||||
}
|
||||
// 配额数据超过 5 分钟未更新,也触发刷新
|
||||
const updatedAt = meta?.codex?.updated_at
|
||||
const updatedAt = getCodexQuotaDisplay(key)?.updated_at
|
||||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||
return true
|
||||
}
|
||||
@@ -1920,14 +2154,11 @@ function shouldAutoRefreshAntigravityQuota(): boolean {
|
||||
|
||||
if (isTokenExpiringSoon(key, now)) return true
|
||||
|
||||
const meta = key.upstream_metadata
|
||||
const updatedAt = meta?.antigravity?.updated_at
|
||||
const quotaByModel = meta?.antigravity?.quota_by_model
|
||||
|
||||
// 只要有一个活跃 key 没有配额/为空/过期,就刷新一次(接口会批量刷新所有活跃 key)
|
||||
if (!quotaByModel || typeof quotaByModel !== 'object' || Object.keys(quotaByModel).length === 0) {
|
||||
if (!hasAntigravityQuotaDisplayData(key)) {
|
||||
return true
|
||||
}
|
||||
const updatedAt = getAntigravityQuotaUpdatedAt(key)
|
||||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||
return true
|
||||
}
|
||||
@@ -1945,13 +2176,12 @@ function shouldAutoRefreshKiroQuota(): boolean {
|
||||
|
||||
if (isTokenExpiringSoon(key, now)) return true
|
||||
|
||||
const meta = key.upstream_metadata
|
||||
// 只要有一个活跃 key 没有配额数据,就刷新一次
|
||||
if (!hasKiroQuotaData(meta)) {
|
||||
if (!hasKiroQuotaDisplayData(key)) {
|
||||
return true
|
||||
}
|
||||
// 配额数据超过 5 分钟未更新,也触发刷新
|
||||
const updatedAt = meta?.kiro?.updated_at
|
||||
const updatedAt = getKiroQuotaDisplay(key)?.updated_at
|
||||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||
return true
|
||||
}
|
||||
@@ -1960,15 +2190,81 @@ function shouldAutoRefreshKiroQuota(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function defaultQuotaSnapshot(): QuotaStatusSnapshot {
|
||||
return {
|
||||
code: 'unknown',
|
||||
exhausted: false,
|
||||
usage_ratio: null,
|
||||
updated_at: null,
|
||||
reset_seconds: null,
|
||||
plan_type: null,
|
||||
}
|
||||
}
|
||||
|
||||
function wrapQuotaMetadataForProvider(
|
||||
providerType: string,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): UpstreamMetadata | null {
|
||||
if (!metadata) return null
|
||||
if (providerType in metadata) {
|
||||
return metadata as UpstreamMetadata
|
||||
}
|
||||
return { [providerType]: metadata } as UpstreamMetadata
|
||||
}
|
||||
|
||||
// 将配额刷新结果就地应用到现有 key 上,避免重新拉列表导致分页重置
|
||||
function applyQuotaResults(results: { key_id: string; status: string; metadata?: Record<string, unknown> }[]) {
|
||||
function applyQuotaResults(
|
||||
results: { key_id: string; status: string; metadata?: Record<string, unknown>; quota_snapshot?: QuotaStatusSnapshot }[],
|
||||
): number {
|
||||
const providerType = provider.value?.provider_type
|
||||
if (!providerType) return 0
|
||||
|
||||
let applied = 0
|
||||
for (const r of results) {
|
||||
if (r.status !== 'success' || !r.metadata) continue
|
||||
const target = providerKeys.value.find(k => k.id === r.key_id)
|
||||
if (target) {
|
||||
target.upstream_metadata = { ...target.upstream_metadata, ...r.metadata } as typeof target.upstream_metadata
|
||||
if (!target) continue
|
||||
|
||||
let changed = false
|
||||
const wrappedMetadata = wrapQuotaMetadataForProvider(providerType, r.metadata)
|
||||
if (wrappedMetadata) {
|
||||
target.upstream_metadata = { ...target.upstream_metadata, ...wrappedMetadata } as typeof target.upstream_metadata
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (r.quota_snapshot) {
|
||||
target.status_snapshot = {
|
||||
oauth: target.status_snapshot?.oauth ?? {
|
||||
code: 'none',
|
||||
label: null,
|
||||
reason: null,
|
||||
expires_at: null,
|
||||
invalid_at: null,
|
||||
source: null,
|
||||
requires_reauth: false,
|
||||
expiring_soon: false,
|
||||
},
|
||||
account: target.status_snapshot?.account ?? {
|
||||
code: 'ok',
|
||||
label: null,
|
||||
reason: null,
|
||||
blocked: false,
|
||||
source: null,
|
||||
recoverable: false,
|
||||
},
|
||||
quota: {
|
||||
...defaultQuotaSnapshot(),
|
||||
...(target.status_snapshot?.quota ?? {}),
|
||||
...r.quota_snapshot,
|
||||
},
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
applied += 1
|
||||
}
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// 通用的自动刷新配额函数(支持 Codex、Antigravity 和 Kiro)
|
||||
@@ -1992,20 +2288,18 @@ async function autoRefreshQuotaInBackground() {
|
||||
|
||||
let hadCachedQuota = false
|
||||
if (providerType === 'codex') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaData(key.upstream_metadata))
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaDisplayData(key))
|
||||
} else if (providerType === 'antigravity') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && key.upstream_metadata && hasAntigravityQuotaData(key.upstream_metadata))
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasAntigravityQuotaDisplayData(key))
|
||||
} else if (providerType === 'kiro') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaData(key.upstream_metadata))
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
|
||||
}
|
||||
|
||||
refreshingQuota.value = true
|
||||
try {
|
||||
const result = await refreshProviderQuota(props.providerId)
|
||||
if (result.success > 0) {
|
||||
// 就地更新 key 的 upstream_metadata,避免重新拉列表导致分页重置
|
||||
applyQuotaResults(result.results)
|
||||
} else if (!hadCachedQuota && providerType === 'antigravity') {
|
||||
const applied = applyQuotaResults(result.results)
|
||||
if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') {
|
||||
showError('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)', '提示')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
@@ -2022,18 +2316,16 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
|
||||
antigravityQuotaDialogOpen.value = true
|
||||
|
||||
// 没有配额数据时主动获取
|
||||
if (!key.upstream_metadata || !hasAntigravityQuotaData(key.upstream_metadata)) {
|
||||
if (!hasAntigravityQuotaDisplayData(key)) {
|
||||
if (refreshingQuota.value) return
|
||||
refreshingQuota.value = true
|
||||
try {
|
||||
const result = await refreshProviderQuota(props.providerId)
|
||||
if (result.success > 0) {
|
||||
applyQuotaResults(result.results)
|
||||
// 更新弹窗引用的 key 数据
|
||||
const updated = allKeys.value.find(({ key: k }) => k.id === key.id)
|
||||
if (updated) {
|
||||
antigravityQuotaDialogKey.value = updated.key
|
||||
}
|
||||
applyQuotaResults(result.results)
|
||||
// 更新弹窗引用的 key 数据
|
||||
const updated = allKeys.value.find(({ key: k }) => k.id === key.id)
|
||||
if (updated) {
|
||||
antigravityQuotaDialogKey.value = updated.key
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,弹窗会显示"暂无配额数据"
|
||||
@@ -2478,7 +2770,7 @@ function getQuotaRemainingBarColor(usedPercent: number): string {
|
||||
|
||||
// 判断是否为 Codex Team/Plus/Enterprise 账号(有 5H 限额,显示 3 列)
|
||||
function isCodexTeamPlan(key: EndpointAPIKey): boolean {
|
||||
const planType = key.oauth_plan_type?.toLowerCase() || key.upstream_metadata?.codex?.plan_type?.toLowerCase()
|
||||
const planType = key.oauth_plan_type?.toLowerCase() || getCodexQuotaDisplay(key)?.plan_type?.toLowerCase()
|
||||
// Free 账号返回 false(2 列),其他所有账号返回 true(3 列)
|
||||
return planType !== undefined && planType !== 'free'
|
||||
}
|
||||
@@ -2496,6 +2788,14 @@ function hasAntigravityQuotaData(metadata: UpstreamMetadata | null | undefined):
|
||||
return !!quotaByModel && typeof quotaByModel === 'object' && Object.keys(quotaByModel).length > 0
|
||||
}
|
||||
|
||||
function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||||
if (Array.isArray(quota?.windows) && quota.windows.length > 0) {
|
||||
return true
|
||||
}
|
||||
return hasAntigravityQuotaData(key.upstream_metadata)
|
||||
}
|
||||
|
||||
function formatUpdatedAt(updatedAt: number): string {
|
||||
if (!updatedAt || typeof updatedAt !== 'number') return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -2564,6 +2864,45 @@ function getAntigravityQuotaItems(metadata: UpstreamMetadata | null | undefined)
|
||||
return items
|
||||
}
|
||||
|
||||
function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQuotaItem[] {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||||
const windows = getQuotaWindowByScope(quota, 'model')
|
||||
if (!quota || windows.length === 0) return []
|
||||
|
||||
const items = windows
|
||||
.map((window) => {
|
||||
const model = String(window.model || window.label || window.code || '').trim()
|
||||
if (!model) return null
|
||||
|
||||
const usedPercent = getQuotaWindowUsedPercent(window)
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (usedPercent === undefined && remainingPercent === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedUsedPercent =
|
||||
usedPercent !== undefined
|
||||
? usedPercent
|
||||
: Math.max(100 - (remainingPercent ?? 0), 0)
|
||||
const normalizedRemainingPercent =
|
||||
remainingPercent !== undefined
|
||||
? remainingPercent
|
||||
: Math.max(100 - normalizedUsedPercent, 0)
|
||||
|
||||
return {
|
||||
model,
|
||||
label: String(window.label || window.model || model),
|
||||
usedPercent: normalizedUsedPercent,
|
||||
remainingPercent: normalizedRemainingPercent,
|
||||
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
|
||||
} satisfies AntigravityQuotaItem
|
||||
})
|
||||
.filter((item): item is AntigravityQuotaItem => item !== null)
|
||||
|
||||
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
|
||||
return items
|
||||
}
|
||||
|
||||
// Antigravity 配额分组定义(按匹配优先级排列,具体规则在前)
|
||||
interface AntigravityQuotaGroup {
|
||||
key: string
|
||||
@@ -2633,6 +2972,53 @@ function getAntigravityQuotaSummary(metadata: UpstreamMetadata | null | undefine
|
||||
return result
|
||||
}
|
||||
|
||||
function getAntigravityQuotaSummaryForKey(key: EndpointAPIKey): AntigravityQuotaSummaryItem[] {
|
||||
const snapshotItems = getAntigravityQuotaItemsFromSnapshot(key)
|
||||
if (snapshotItems.length > 0) {
|
||||
const groupMap = new Map<string, { label: string, maxUsed: number, resetSeconds: number | null }>()
|
||||
|
||||
for (const item of snapshotItems) {
|
||||
const model = item.model.toLowerCase()
|
||||
const group = ANTIGRAVITY_QUOTA_GROUPS.find(g => g.match(model))
|
||||
if (!group) continue
|
||||
|
||||
const existing = groupMap.get(group.key)
|
||||
if (!existing) {
|
||||
groupMap.set(group.key, {
|
||||
label: group.label,
|
||||
maxUsed: item.usedPercent,
|
||||
resetSeconds: item.resetSeconds,
|
||||
})
|
||||
} else {
|
||||
if (item.usedPercent > existing.maxUsed) {
|
||||
existing.maxUsed = item.usedPercent
|
||||
}
|
||||
if (existing.resetSeconds === null) {
|
||||
existing.resetSeconds = item.resetSeconds
|
||||
} else if (item.resetSeconds !== null && item.resetSeconds < existing.resetSeconds) {
|
||||
existing.resetSeconds = item.resetSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: AntigravityQuotaSummaryItem[] = []
|
||||
for (const group of ANTIGRAVITY_QUOTA_GROUPS) {
|
||||
const data = groupMap.get(group.key)
|
||||
if (!data) continue
|
||||
result.push({
|
||||
key: group.key,
|
||||
label: data.label,
|
||||
usedPercent: data.maxUsed,
|
||||
remainingPercent: Math.max(100 - data.maxUsed, 0),
|
||||
resetSeconds: data.resetSeconds,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return getAntigravityQuotaSummary(key.upstream_metadata)
|
||||
}
|
||||
|
||||
function getResetCountdownText(
|
||||
resetAt: number | null | undefined,
|
||||
resetSecs: number | null | undefined,
|
||||
|
||||
225
frontend/src/utils/providerKeyQuota.ts
Normal file
225
frontend/src/utils/providerKeyQuota.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import type {
|
||||
ProviderKeyStatusSnapshot,
|
||||
QuotaStatusSnapshot,
|
||||
QuotaWindowSnapshot,
|
||||
} from '@/api/endpoints/types/statusSnapshot'
|
||||
|
||||
export interface ProviderKeyQuotaCarrier {
|
||||
account_quota?: string | null
|
||||
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const text = value.trim()
|
||||
return text || null
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
if (value < 0) return 0
|
||||
if (value > 100) return 100
|
||||
return value
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${clampPercent(value).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function getQuotaSnapshot(
|
||||
input: ProviderKeyQuotaCarrier,
|
||||
): QuotaStatusSnapshot | null {
|
||||
return input.status_snapshot?.quota ?? null
|
||||
}
|
||||
|
||||
function getQuotaProviderType(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
fallbackProviderType?: string | null,
|
||||
): string {
|
||||
const snapshotProviderType = normalizeText(quota?.provider_type)?.toLowerCase()
|
||||
if (snapshotProviderType) return snapshotProviderType
|
||||
return normalizeText(fallbackProviderType)?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
function getQuotaWindows(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
): QuotaWindowSnapshot[] {
|
||||
return Array.isArray(quota?.windows) ? quota.windows : []
|
||||
}
|
||||
|
||||
function getQuotaWindowRemainingPercent(
|
||||
window: QuotaWindowSnapshot | null | undefined,
|
||||
): number | null {
|
||||
if (!window) return null
|
||||
if (typeof window.remaining_ratio === 'number') {
|
||||
return clampPercent(window.remaining_ratio * 100)
|
||||
}
|
||||
if (typeof window.used_ratio === 'number') {
|
||||
return clampPercent((1 - window.used_ratio) * 100)
|
||||
}
|
||||
if (typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||
if (typeof window.remaining_value === 'number') {
|
||||
return clampPercent((window.remaining_value / window.limit_value) * 100)
|
||||
}
|
||||
if (typeof window.used_value === 'number') {
|
||||
return clampPercent((1 - (window.used_value / window.limit_value)) * 100)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getQuotaWindow(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
code: string,
|
||||
): QuotaWindowSnapshot | null {
|
||||
const normalizedCode = code.trim().toLowerCase()
|
||||
return getQuotaWindows(quota).find(window => normalizeText(window.code)?.toLowerCase() === normalizedCode) ?? null
|
||||
}
|
||||
|
||||
function getQuotaWindowsByScope(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
scope: string,
|
||||
): QuotaWindowSnapshot[] {
|
||||
const normalizedScope = scope.trim().toLowerCase()
|
||||
return getQuotaWindows(quota).filter(window => normalizeText(window.scope)?.toLowerCase() === normalizedScope)
|
||||
}
|
||||
|
||||
function formatQuotaValue(value: number | null | undefined): string {
|
||||
const normalized = Number(value)
|
||||
if (!Number.isFinite(normalized)) return '0'
|
||||
const rounded = Math.round(normalized)
|
||||
if (Math.abs(normalized - rounded) < 1e-6) {
|
||||
return String(rounded)
|
||||
}
|
||||
return normalized.toFixed(1)
|
||||
}
|
||||
|
||||
function getCodexQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const parts: string[] = []
|
||||
for (const [label, code] of [['周', 'weekly'], ['5H', '5h']] as const) {
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(getQuotaWindow(quota, code))
|
||||
if (remainingPercent == null) continue
|
||||
parts.push(`${label}剩余 ${formatPercent(remainingPercent)}`)
|
||||
}
|
||||
if (parts.length > 0) return parts.join(' | ')
|
||||
|
||||
if (quota.credits?.has_credits === true && typeof quota.credits.balance === 'number') {
|
||||
return `积分 ${quota.credits.balance.toFixed(2)}`
|
||||
}
|
||||
if (quota.credits?.has_credits === true) return '有积分'
|
||||
if (quota.credits?.has_credits === false) return '无可用积分'
|
||||
|
||||
return normalizeText(quota.label)
|
||||
}
|
||||
|
||||
function getKiroQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const code = normalizeText(quota.code)?.toLowerCase()
|
||||
if (code === 'banned') {
|
||||
return normalizeText(quota.label) || '账号已封禁'
|
||||
}
|
||||
|
||||
const window = getQuotaWindow(quota, 'usage') ?? getQuotaWindowsByScope(quota, 'account')[0] ?? null
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (typeof window?.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0 && window.remaining_value <= 0) {
|
||||
return `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
if (remainingPercent != null) {
|
||||
if (typeof window?.used_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||
return `剩余 ${formatPercent(remainingPercent)} (${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)})`
|
||||
}
|
||||
return `剩余 ${formatPercent(remainingPercent)}`
|
||||
}
|
||||
|
||||
if (typeof window?.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||
return `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
|
||||
return normalizeText(quota.label)
|
||||
}
|
||||
|
||||
function getAntigravityQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const code = normalizeText(quota.code)?.toLowerCase()
|
||||
if (code === 'forbidden') {
|
||||
return normalizeText(quota.label) || '访问受限'
|
||||
}
|
||||
|
||||
const remainingList = getQuotaWindowsByScope(quota, 'model')
|
||||
.map(getQuotaWindowRemainingPercent)
|
||||
.filter((value): value is number => value != null)
|
||||
|
||||
if (remainingList.length === 0) return normalizeText(quota.label)
|
||||
|
||||
const minimumRemaining = Math.min(...remainingList)
|
||||
if (remainingList.length === 1) {
|
||||
return `剩余 ${formatPercent(minimumRemaining)}`
|
||||
}
|
||||
return `最低剩余 ${formatPercent(minimumRemaining)} (${remainingList.length} 模型)`
|
||||
}
|
||||
|
||||
function getGeminiCliQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const modelWindows = getQuotaWindowsByScope(quota, 'model')
|
||||
const activeCoolingModels = modelWindows
|
||||
.filter((window) => {
|
||||
if (window.is_exhausted === true) return true
|
||||
if (typeof window.used_ratio === 'number') return window.used_ratio >= 1.0 - 1e-6
|
||||
return false
|
||||
})
|
||||
.filter((window) => {
|
||||
if (typeof window.reset_at !== 'number') return true
|
||||
return window.reset_at > Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.map((window) => normalizeText(window.label) || normalizeText(window.model) || '模型')
|
||||
|
||||
if (activeCoolingModels.length === 1) {
|
||||
return `${activeCoolingModels[0]} 冷却中`
|
||||
}
|
||||
if (activeCoolingModels.length > 1) {
|
||||
return `${activeCoolingModels.length} 个模型冷却中`
|
||||
}
|
||||
|
||||
const remainingList = modelWindows
|
||||
.map(getQuotaWindowRemainingPercent)
|
||||
.filter((value): value is number => value != null)
|
||||
if (remainingList.length === 0) return normalizeText(quota.label)
|
||||
|
||||
const minimumRemaining = Math.min(...remainingList)
|
||||
if (remainingList.length === 1) {
|
||||
return `剩余 ${formatPercent(minimumRemaining)}`
|
||||
}
|
||||
return `最低剩余 ${formatPercent(minimumRemaining)} (${remainingList.length} 模型)`
|
||||
}
|
||||
|
||||
export function getLegacyAccountQuotaText(
|
||||
input: ProviderKeyQuotaCarrier,
|
||||
): string | null {
|
||||
return normalizeText(input.account_quota)
|
||||
}
|
||||
|
||||
export function getQuotaSnapshotFallbackText(
|
||||
input: ProviderKeyQuotaCarrier,
|
||||
fallbackProviderType?: string | null,
|
||||
): string | null {
|
||||
const quota = getQuotaSnapshot(input)
|
||||
if (!quota) return null
|
||||
|
||||
const providerType = getQuotaProviderType(quota, fallbackProviderType)
|
||||
switch (providerType) {
|
||||
case 'codex':
|
||||
return getCodexQuotaText(quota)
|
||||
case 'kiro':
|
||||
return getKiroQuotaText(quota)
|
||||
case 'antigravity':
|
||||
return getAntigravityQuotaText(quota)
|
||||
case 'gemini_cli':
|
||||
return getGeminiCliQuotaText(quota)
|
||||
default:
|
||||
return normalizeText(quota.label)
|
||||
}
|
||||
}
|
||||
|
||||
export function getQuotaDisplayText(
|
||||
input: ProviderKeyQuotaCarrier,
|
||||
fallbackProviderType?: string | null,
|
||||
): string | null {
|
||||
return getQuotaSnapshotFallbackText(input, fallbackProviderType) || getLegacyAccountQuotaText(input)
|
||||
}
|
||||
@@ -546,10 +546,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="key.account_quota"
|
||||
:class="getQuotaTextClass(key.account_quota)"
|
||||
v-else-if="getQuotaFallbackText(key)"
|
||||
:class="getQuotaTextClass(getQuotaFallbackText(key) || '')"
|
||||
>
|
||||
{{ key.account_quota }}
|
||||
{{ getQuotaFallbackText(key) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
@@ -833,10 +833,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="key.account_quota"
|
||||
:class="getQuotaTextClass(key.account_quota)"
|
||||
v-else-if="getQuotaFallbackText(key)"
|
||||
:class="getQuotaTextClass(getQuotaFallbackText(key) || '')"
|
||||
>
|
||||
{{ key.account_quota }}
|
||||
{{ getQuotaFallbackText(key) }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -1180,6 +1180,7 @@ import type {
|
||||
PoolAdvancedConfig,
|
||||
ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints/types/provider'
|
||||
import type { QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||
import { getProvider, updateProvider } from '@/api/endpoints'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
|
||||
@@ -1223,6 +1224,10 @@ import {
|
||||
getOAuthStatusDisplay,
|
||||
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
import {
|
||||
getLegacyAccountQuotaText,
|
||||
getQuotaDisplayText,
|
||||
} from '@/utils/providerKeyQuota'
|
||||
|
||||
const { success, error: showError, warning: showWarning } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
@@ -2569,15 +2574,39 @@ function getOAuthStatusTitle(key: PoolKeyDetail): string {
|
||||
|
||||
const _accountAlertCache = new WeakMap<PoolKeyDetail, string | null>()
|
||||
|
||||
function getQuotaAlertSnapshotState(key: PoolKeyDetail): { label: string, title: string } | null {
|
||||
const quota = getQuotaSnapshot(key)
|
||||
if (!quota) return null
|
||||
|
||||
const code = String(quota.code || '').trim().toLowerCase()
|
||||
if (code !== 'banned' && code !== 'forbidden') return null
|
||||
|
||||
let label = String(quota.label || '').trim()
|
||||
if (!label) {
|
||||
label = code === 'banned' ? '账号封禁' : '访问受限'
|
||||
} else if (label === '账号已封禁' || label === '封禁') {
|
||||
label = '账号封禁'
|
||||
}
|
||||
|
||||
const reason = String(quota.reason || '').trim()
|
||||
return {
|
||||
label,
|
||||
title: reason ? `${label}: ${reason}` : label,
|
||||
}
|
||||
}
|
||||
|
||||
function getAccountAlertLabel(key: PoolKeyDetail): string | null {
|
||||
const cached = _accountAlertCache.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
let result: string | null = getAccountStatusDisplay(key).label
|
||||
const quotaText = String(key.account_quota || '').trim()
|
||||
// 后端 _build_account_quota 返回的确切文本: "账号已封禁" / "访问受限"
|
||||
if (!result && (quotaText === '账号已封禁' || quotaText === '封禁')) result = '账号封禁'
|
||||
else if (!result && quotaText === '访问受限') result = '访问受限'
|
||||
const quotaAlert = getQuotaAlertSnapshotState(key)
|
||||
if (!result && quotaAlert) result = quotaAlert.label
|
||||
if (!result && !getQuotaSnapshot(key)) {
|
||||
const quotaText = getLegacyAccountQuotaText(key)
|
||||
if (quotaText === '账号已封禁' || quotaText === '封禁') result = '账号封禁'
|
||||
else if (quotaText === '访问受限') result = '访问受限'
|
||||
}
|
||||
|
||||
_accountAlertCache.set(key, result)
|
||||
return result
|
||||
@@ -2590,7 +2619,10 @@ function getAccountAlertTitle(key: PoolKeyDetail): string {
|
||||
const accountTitle = getAccountStatusTitle(key)
|
||||
if (accountTitle) return accountTitle
|
||||
|
||||
const quotaText = String(key.account_quota || '').trim()
|
||||
const quotaAlert = getQuotaAlertSnapshotState(key)
|
||||
if (quotaAlert?.title) return quotaAlert.title
|
||||
|
||||
const quotaText = getLegacyAccountQuotaText(key)
|
||||
if (quotaText) return `${label}: ${quotaText}`
|
||||
return label
|
||||
}
|
||||
@@ -2646,6 +2678,10 @@ function getQuotaProgressDisplayText(item: QuotaProgressItem): string {
|
||||
return item.detail?.trim() || ''
|
||||
}
|
||||
|
||||
function getQuotaFallbackText(key: PoolKeyDetail): string | null {
|
||||
return getQuotaDisplayText(key, selectedProviderType.value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getQuotaLabelOrder(label: string): number {
|
||||
@@ -2676,24 +2712,186 @@ function normalizeRemainingSeconds(raw: number | null | undefined): number | nul
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
function getQuotaSnapshot(key: PoolKeyDetail): QuotaStatusSnapshot | null {
|
||||
const quota = key.status_snapshot?.quota
|
||||
if (!quota) return null
|
||||
return quota
|
||||
}
|
||||
|
||||
function getQuotaSnapshotProviderType(key: PoolKeyDetail): string {
|
||||
const snapshotProviderType = String(getQuotaSnapshot(key)?.provider_type || '').trim().toLowerCase()
|
||||
if (snapshotProviderType) return snapshotProviderType
|
||||
return selectedProviderType.value
|
||||
}
|
||||
|
||||
function getCodexQuotaSnapshot(key: PoolKeyDetail): QuotaStatusSnapshot | null {
|
||||
const quota = getQuotaSnapshot(key)
|
||||
if (!quota) return null
|
||||
return getQuotaSnapshotProviderType(key) === 'codex' ? quota : null
|
||||
}
|
||||
|
||||
function getQuotaSnapshotUpdatedAtSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
|
||||
return normalizeUnixSeconds(quota?.updated_at ?? quota?.observed_at ?? null)
|
||||
}
|
||||
|
||||
function getQuotaSnapshotWindow(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
code: string,
|
||||
): QuotaWindowSnapshot | null {
|
||||
const windows = quota?.windows
|
||||
if (!Array.isArray(windows)) return null
|
||||
|
||||
const normalizedCode = code.trim().toLowerCase()
|
||||
return windows.find(window => String(window?.code || '').trim().toLowerCase() === normalizedCode) ?? null
|
||||
}
|
||||
|
||||
function getQuotaSnapshotWindowsByScope(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
scope: string,
|
||||
): QuotaWindowSnapshot[] {
|
||||
const windows = quota?.windows
|
||||
if (!Array.isArray(windows)) return []
|
||||
|
||||
const normalizedScope = scope.trim().toLowerCase()
|
||||
return windows.filter(window => String(window?.scope || '').trim().toLowerCase() === normalizedScope)
|
||||
}
|
||||
|
||||
function getQuotaWindowUsedPercent(window: QuotaWindowSnapshot | null | undefined): number | null {
|
||||
if (!window) return null
|
||||
if (typeof window.used_ratio === 'number') {
|
||||
return clampPercent(window.used_ratio * 100)
|
||||
}
|
||||
if (typeof window.remaining_ratio === 'number') {
|
||||
return clampPercent((1 - window.remaining_ratio) * 100)
|
||||
}
|
||||
if (typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||
if (typeof window.remaining_value === 'number') {
|
||||
return clampPercent((1 - (window.remaining_value / window.limit_value)) * 100)
|
||||
}
|
||||
if (typeof window.used_value === 'number') {
|
||||
return clampPercent((window.used_value / window.limit_value) * 100)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getQuotaWindowRemainingPercent(window: QuotaWindowSnapshot | null | undefined): number | null {
|
||||
if (!window) return null
|
||||
if (typeof window.remaining_ratio === 'number') {
|
||||
return clampPercent(window.remaining_ratio * 100)
|
||||
}
|
||||
const usedPercent = getQuotaWindowUsedPercent(window)
|
||||
return usedPercent == null ? null : clampPercent(100 - usedPercent)
|
||||
}
|
||||
|
||||
function formatQuotaValue(value: number | null | undefined): string {
|
||||
const normalized = Number(value)
|
||||
if (!Number.isFinite(normalized)) return '0'
|
||||
const rounded = Math.round(normalized)
|
||||
if (Math.abs(normalized - rounded) < 1e-6) {
|
||||
return String(rounded)
|
||||
}
|
||||
return normalized.toFixed(1)
|
||||
}
|
||||
|
||||
function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressItem[] {
|
||||
const quota = getQuotaSnapshot(key)
|
||||
if (!quota) return []
|
||||
|
||||
const providerType = getQuotaSnapshotProviderType(key)
|
||||
|
||||
if (providerType === 'codex') {
|
||||
const items: QuotaProgressItem[] = []
|
||||
for (const [label, code] of [['5H', '5h'], ['周', 'weekly']] as const) {
|
||||
const window = getQuotaSnapshotWindow(quota, code)
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) continue
|
||||
items.push({
|
||||
label,
|
||||
remainingPercent,
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
if (providerType === 'kiro') {
|
||||
const window = getQuotaSnapshotWindow(quota, 'usage')
|
||||
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
|
||||
?? null
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) return []
|
||||
|
||||
const detail = typeof window?.used_value === 'number' && typeof window?.limit_value === 'number'
|
||||
? `${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
: undefined
|
||||
|
||||
return [{
|
||||
label: '剩余',
|
||||
remainingPercent,
|
||||
detail,
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
}]
|
||||
}
|
||||
|
||||
if (providerType === 'antigravity') {
|
||||
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||
if (windows.length === 0) return []
|
||||
|
||||
const remainingPercents = windows
|
||||
.map(getQuotaWindowRemainingPercent)
|
||||
.filter((value): value is number => value != null)
|
||||
if (remainingPercents.length === 0) return []
|
||||
|
||||
return [{
|
||||
label: '最低',
|
||||
remainingPercent: Math.min(...remainingPercents),
|
||||
detail: `${windows.length} 模型`,
|
||||
resetAtSeconds: null,
|
||||
resetSeconds: null,
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
}]
|
||||
}
|
||||
|
||||
if (providerType === 'gemini_cli') {
|
||||
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||
if (windows.length === 0) return []
|
||||
|
||||
const remainingPercents = windows
|
||||
.map(getQuotaWindowRemainingPercent)
|
||||
.filter((value): value is number => value != null)
|
||||
if (remainingPercents.length === 0) return []
|
||||
|
||||
return [{
|
||||
label: '最低',
|
||||
remainingPercent: Math.min(...remainingPercents),
|
||||
detail: `${windows.length} 模型`,
|
||||
resetAtSeconds: null,
|
||||
resetSeconds: null,
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
}]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function resolveCodexQuotaCountdown(
|
||||
key: PoolKeyDetail,
|
||||
label: string
|
||||
): Pick<QuotaProgressItem, 'resetAtSeconds' | 'resetSeconds' | 'updatedAtSeconds'> | null {
|
||||
if (label !== '5H' && label !== '周') return null
|
||||
const codex = key.upstream_metadata?.codex
|
||||
if (!codex) return null
|
||||
|
||||
const isWeeklyWindow = label === '周'
|
||||
const resetAtSeconds = normalizeUnixSeconds(
|
||||
isWeeklyWindow ? codex.primary_reset_at : codex.secondary_reset_at
|
||||
)
|
||||
const resetSeconds = normalizeRemainingSeconds(
|
||||
isWeeklyWindow
|
||||
? (codex.primary_reset_seconds ?? codex.primary_reset_after_seconds ?? null)
|
||||
: (codex.secondary_reset_seconds ?? codex.secondary_reset_after_seconds ?? null)
|
||||
)
|
||||
const updatedAtSeconds = normalizeUnixSeconds(codex.updated_at)
|
||||
const codexSnapshot = getCodexQuotaSnapshot(key)
|
||||
const snapshotWindow = getQuotaSnapshotWindow(codexSnapshot, label === '周' ? 'weekly' : '5h')
|
||||
if (!snapshotWindow) return null
|
||||
|
||||
const resetAtSeconds = normalizeUnixSeconds(snapshotWindow.reset_at ?? null)
|
||||
const resetSeconds = normalizeRemainingSeconds(snapshotWindow.reset_seconds ?? null)
|
||||
const updatedAtSeconds = getQuotaSnapshotUpdatedAtSeconds(codexSnapshot)
|
||||
|
||||
if (resetAtSeconds == null && resetSeconds == null) return null
|
||||
return { resetAtSeconds, resetSeconds, updatedAtSeconds }
|
||||
@@ -2723,7 +2921,18 @@ function parseQuotaResetRemainingSeconds(detail: string | undefined): number | n
|
||||
}
|
||||
|
||||
function parseQuotaProgressItems(key: PoolKeyDetail): QuotaProgressItem[] {
|
||||
const quotaText = key.account_quota
|
||||
const snapshotItems = buildQuotaProgressItemsFromSnapshot(key)
|
||||
if (snapshotItems.length > 0) {
|
||||
return snapshotItems.sort((a, b) => {
|
||||
const orderDiff = getQuotaLabelOrder(a.label) - getQuotaLabelOrder(b.label)
|
||||
if (orderDiff !== 0) return orderDiff
|
||||
return a.label.localeCompare(b.label, 'zh-Hans-CN')
|
||||
})
|
||||
}
|
||||
|
||||
if (getQuotaSnapshot(key)) return []
|
||||
|
||||
const quotaText = getLegacyAccountQuotaText(key)
|
||||
if (!quotaText) return []
|
||||
|
||||
const segments = quotaText
|
||||
|
||||
Reference in New Issue
Block a user