feat: add AI Credits balance handling and update model status indicators
This commit is contained in:
@@ -76,19 +76,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) -->
|
||||
<!-- Model Status Indicators (普通限流 / 超量请求中) -->
|
||||
<div
|
||||
v-if="activeModelRateLimits.length > 0"
|
||||
v-if="activeModelStatuses.length > 0"
|
||||
:class="[
|
||||
activeModelRateLimits.length <= 4
|
||||
activeModelStatuses.length <= 4
|
||||
? 'flex flex-col gap-1'
|
||||
: activeModelRateLimits.length <= 8
|
||||
: activeModelStatuses.length <= 8
|
||||
? 'columns-2 gap-x-2'
|
||||
: 'columns-3 gap-x-2'
|
||||
]"
|
||||
>
|
||||
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative mb-1 break-inside-avoid">
|
||||
<div v-for="item in activeModelStatuses" :key="`${item.kind}-${item.model}`" class="group relative mb-1 break-inside-avoid">
|
||||
<span
|
||||
v-if="item.kind === 'overages'"
|
||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
<span>⚡</span>
|
||||
{{ formatScopeName(item.model) }}
|
||||
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
@@ -99,7 +108,11 @@
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) }}
|
||||
{{
|
||||
item.kind === 'overages'
|
||||
? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
|
||||
: t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
|
||||
}}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
@@ -131,6 +144,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { Account } from '@/types'
|
||||
import { formatCountdown, formatDateTime, formatCountdownWithSuffix, formatTime } from '@/utils/format'
|
||||
|
||||
@@ -150,17 +164,37 @@ const isRateLimited = computed(() => {
|
||||
return new Date(props.account.rate_limit_reset_at) > new Date()
|
||||
})
|
||||
|
||||
type AccountModelStatusItem = {
|
||||
kind: 'rate_limit' | 'overages'
|
||||
model: string
|
||||
reset_at: string
|
||||
}
|
||||
|
||||
// Computed: active model rate limits (Antigravity OAuth Smart Retry)
|
||||
const activeModelRateLimits = computed(() => {
|
||||
const modelLimits = (props.account.extra as Record<string, unknown> | undefined)?.model_rate_limits as
|
||||
// Computed: active model statuses (普通模型限流 + 超量请求运行态)
|
||||
const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
|
||||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||||
const modelLimits = extra?.model_rate_limits as
|
||||
| Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
||||
| undefined
|
||||
if (!modelLimits) return []
|
||||
const now = new Date()
|
||||
return Object.entries(modelLimits)
|
||||
.filter(([, info]) => new Date(info.rate_limit_reset_at) > now)
|
||||
.map(([model, info]) => ({ model, reset_at: info.rate_limit_reset_at }))
|
||||
const items: AccountModelStatusItem[] = []
|
||||
|
||||
if (modelLimits) {
|
||||
items.push(...Object.entries(modelLimits)
|
||||
.filter(([, info]) => new Date(info.rate_limit_reset_at) > now)
|
||||
.map(([model, info]) => ({ kind: 'rate_limit' as const, model, reset_at: info.rate_limit_reset_at })))
|
||||
}
|
||||
|
||||
const overagesStates = extra?.antigravity_credits_overages as
|
||||
| Record<string, { activated_at?: string; active_until: string }>
|
||||
| undefined
|
||||
if (overagesStates) {
|
||||
items.push(...Object.entries(overagesStates)
|
||||
.filter(([, info]) => new Date(info.active_until) > now)
|
||||
.map(([model, info]) => ({ kind: 'overages' as const, model, reset_at: info.active_until })))
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const formatScopeName = (scope: string): string => {
|
||||
|
||||
@@ -289,6 +289,33 @@
|
||||
:resets-at="antigravityClaudeUsageFromAPI.resetTime"
|
||||
color="amber"
|
||||
/>
|
||||
|
||||
<div v-if="antigravityAICreditsDisplay.length > 0" class="mt-1 space-y-0.5 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
v-for="credit in antigravityAICreditsDisplay"
|
||||
:key="credit.creditType"
|
||||
>
|
||||
{{ t('admin.accounts.aiCreditsBalance') }}:
|
||||
{{ credit.creditType }}
|
||||
{{ credit.amount }}
|
||||
<span v-if="credit.minimumBalance !== null">
|
||||
(min {{ credit.minimumBalance }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="antigravityAICreditsDisplay.length > 0" class="space-y-0.5 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
v-for="credit in antigravityAICreditsDisplay"
|
||||
:key="credit.creditType"
|
||||
>
|
||||
{{ t('admin.accounts.aiCreditsBalance') }}:
|
||||
{{ credit.creditType }}
|
||||
{{ credit.amount }}
|
||||
<span v-if="credit.minimumBalance !== null">
|
||||
(min {{ credit.minimumBalance }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
@@ -581,6 +608,20 @@ const antigravityClaudeUsageFromAPI = computed(() =>
|
||||
])
|
||||
)
|
||||
|
||||
const antigravityAICreditsDisplay = computed(() => {
|
||||
const credits = usageInfo.value?.ai_credits
|
||||
if (!credits || credits.length === 0) return []
|
||||
return credits
|
||||
.filter((credit) => (credit.amount ?? 0) > 0)
|
||||
.map((credit) => ({
|
||||
creditType: credit.credit_type || 'UNKNOWN',
|
||||
amount: Number(credit.amount ?? 0).toFixed(0),
|
||||
minimumBalance: typeof credit.minimum_balance === 'number'
|
||||
? Number(credit.minimum_balance).toFixed(0)
|
||||
: null,
|
||||
}))
|
||||
})
|
||||
|
||||
// Antigravity 账户类型(从 load_code_assist 响应中提取)
|
||||
const antigravityTier = computed(() => {
|
||||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AccountStatusIndicator from '../AccountStatusIndicator.vue'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function makeAccount(overrides: Partial<Account>): Account {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'account',
|
||||
platform: 'antigravity',
|
||||
type: 'oauth',
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: true,
|
||||
created_at: '2026-03-15T00:00:00Z',
|
||||
updated_at: '2026-03-15T00:00:00Z',
|
||||
schedulable: true,
|
||||
rate_limited_at: null,
|
||||
rate_limit_reset_at: null,
|
||||
overload_until: null,
|
||||
temp_unschedulable_until: null,
|
||||
temp_unschedulable_reason: null,
|
||||
session_window_start: null,
|
||||
session_window_end: null,
|
||||
session_window_status: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AccountStatusIndicator', () => {
|
||||
it('会将超量请求中的模型显示为独立状态', () => {
|
||||
const wrapper = mount(AccountStatusIndicator, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 1,
|
||||
name: 'ag-1',
|
||||
extra: {
|
||||
antigravity_credits_overages: {
|
||||
'claude-sonnet-4-5': {
|
||||
activated_at: '2026-03-15T00:00:00Z',
|
||||
active_until: '2099-03-15T00:00:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('⚡')
|
||||
expect(wrapper.text()).toContain('CSon45')
|
||||
})
|
||||
|
||||
it('普通模型限流仍显示原有限流状态', () => {
|
||||
const wrapper = mount(AccountStatusIndicator, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 2,
|
||||
name: 'ag-2',
|
||||
extra: {
|
||||
model_rate_limits: {
|
||||
'claude-sonnet-4-5': {
|
||||
rate_limited_at: '2026-03-15T00:00:00Z',
|
||||
rate_limit_reset_at: '2099-03-15T00:00:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CSon45')
|
||||
expect(wrapper.text()).not.toContain('⚡')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import AccountUsageCell from '../AccountUsageCell.vue'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const { getUsage } = vi.hoisted(() => ({
|
||||
getUsage: vi.fn()
|
||||
@@ -24,6 +25,35 @@ vi.mock('vue-i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function makeAccount(overrides: Partial<Account>): Account {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'account',
|
||||
platform: 'antigravity',
|
||||
type: 'oauth',
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: true,
|
||||
created_at: '2026-03-15T00:00:00Z',
|
||||
updated_at: '2026-03-15T00:00:00Z',
|
||||
schedulable: true,
|
||||
rate_limited_at: null,
|
||||
rate_limit_reset_at: null,
|
||||
overload_until: null,
|
||||
temp_unschedulable_until: null,
|
||||
temp_unschedulable_reason: null,
|
||||
session_window_start: null,
|
||||
session_window_end: null,
|
||||
session_window_status: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AccountUsageCell', () => {
|
||||
beforeEach(() => {
|
||||
getUsage.mockReset()
|
||||
@@ -49,12 +79,12 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
account: makeAccount({
|
||||
id: 1001,
|
||||
platform: 'antigravity',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
} as any
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -72,6 +102,42 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.gemini3Image|70|2026-03-01T09:00:00Z')
|
||||
})
|
||||
|
||||
it('Antigravity 会显示 AI Credits 余额信息', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
ai_credits: [
|
||||
{
|
||||
credit_type: 'GOOGLE_ONE_AI',
|
||||
amount: 25,
|
||||
minimum_balance: 5
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 1002,
|
||||
platform: 'antigravity',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.aiCreditsBalance')
|
||||
expect(wrapper.text()).toContain('GOOGLE_ONE_AI')
|
||||
expect(wrapper.text()).toContain('25')
|
||||
expect(wrapper.text()).toContain('(min 5)')
|
||||
})
|
||||
|
||||
|
||||
it('OpenAI OAuth 快照已过期时首屏会重新请求 usage', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
@@ -103,7 +169,7 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
account: makeAccount({
|
||||
id: 2000,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
@@ -114,7 +180,7 @@ describe('AccountUsageCell', () => {
|
||||
codex_7d_used_percent: 34,
|
||||
codex_7d_reset_at: '2026-03-13T12:00:00Z'
|
||||
}
|
||||
} as any
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -137,7 +203,7 @@ describe('AccountUsageCell', () => {
|
||||
it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => {
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
account: makeAccount({
|
||||
id: 2001,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
@@ -148,7 +214,7 @@ describe('AccountUsageCell', () => {
|
||||
codex_7d_used_percent: 34,
|
||||
codex_7d_reset_at: '2099-03-13T12:00:00Z'
|
||||
}
|
||||
} as any
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -196,15 +262,15 @@ describe('AccountUsageCell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2002,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
} as any
|
||||
},
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 2002,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
@@ -256,16 +322,16 @@ describe('AccountUsageCell', () => {
|
||||
seven_day: null
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2003,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {}
|
||||
} as any
|
||||
},
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 2003,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
@@ -324,19 +390,19 @@ describe('AccountUsageCell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2004,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
rate_limit_reset_at: '2099-03-07T12:00:00Z',
|
||||
extra: {
|
||||
codex_5h_used_percent: 0,
|
||||
codex_7d_used_percent: 0
|
||||
}
|
||||
} as any
|
||||
},
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 2004,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
rate_limit_reset_at: '2099-03-07T12:00:00Z',
|
||||
extra: {
|
||||
codex_5h_used_percent: 0,
|
||||
codex_7d_used_percent: 0
|
||||
}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
|
||||
Reference in New Issue
Block a user