mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
Replace process-memory sync.Map + per-model runtime state with a single
"AICredits" key in model_rate_limits, making credits exhaustion fully
isomorphic with model-level rate limiting.
Scheduler: rate-limited accounts with overages enabled + credits available
are now scheduled instead of excluded.
Forwarding: when model is rate-limited + credits available, inject credits
proactively without waiting for a 429 round trip.
Storage: credits exhaustion stored as model_rate_limits["AICredits"] with
5h duration, reusing SetModelRateLimit/isRateLimitActiveForKey.
Frontend: show credits_active (yellow ⚡) when model rate-limited but
credits available, credits_exhausted (red) when AICredits key active.
Tests: add unit tests for shouldMarkCreditsExhausted, injectEnabledCreditTypes,
clearCreditsExhausted, and update existing overages tests.
344 lines
13 KiB
Vue
344 lines
13 KiB
Vue
<template>
|
||
<div class="flex items-center gap-2">
|
||
<!-- Rate Limit Display (429) - Two-line layout -->
|
||
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
||
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitResumeText }}</span>
|
||
</div>
|
||
|
||
<!-- Overload Display (529) - Two-line layout -->
|
||
<div v-else-if="isOverloaded" class="flex flex-col items-center gap-1">
|
||
<span class="badge text-xs badge-danger">{{ t('admin.accounts.status.overloaded') }}</span>
|
||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ overloadCountdown }}</span>
|
||
</div>
|
||
|
||
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
|
||
<template v-else>
|
||
<button
|
||
v-if="isTempUnschedulable"
|
||
type="button"
|
||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||
@click="handleTempUnschedClick"
|
||
>
|
||
{{ statusText }}
|
||
</button>
|
||
<span v-else :class="['badge text-xs', statusClass]">
|
||
{{ statusText }}
|
||
</span>
|
||
</template>
|
||
|
||
<!-- Error Info Indicator -->
|
||
<div v-if="hasError && account.error_message" class="group/error relative">
|
||
<svg
|
||
class="h-4 w-4 cursor-help text-red-500 transition-colors hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||
/>
|
||
</svg>
|
||
<!-- Tooltip - 向下显示 -->
|
||
<div
|
||
class="invisible absolute left-0 top-full z-[100] mt-1.5 min-w-[200px] max-w-[300px] rounded-lg bg-gray-800 px-3 py-2 text-xs text-white opacity-0 shadow-xl transition-all duration-200 group-hover/error:visible group-hover/error:opacity-100 dark:bg-gray-900"
|
||
>
|
||
<div class="whitespace-pre-wrap break-words leading-relaxed text-gray-300">
|
||
{{ account.error_message }}
|
||
</div>
|
||
<!-- 上方小三角 -->
|
||
<div
|
||
class="absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rate Limit Indicator (429) -->
|
||
<div v-if="isRateLimited" class="group relative">
|
||
<span
|
||
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"
|
||
>
|
||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||
429
|
||
</span>
|
||
<!-- Tooltip -->
|
||
<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.rateLimitedUntil', { time: formatDateTime(account.rate_limit_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>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Model Status Indicators (普通限流 / 超量请求中) -->
|
||
<div
|
||
v-if="activeModelStatuses.length > 0"
|
||
:class="[
|
||
activeModelStatuses.length <= 4
|
||
? 'flex flex-col gap-1'
|
||
: activeModelStatuses.length <= 8
|
||
? 'columns-2 gap-x-2'
|
||
: 'columns-3 gap-x-2'
|
||
]"
|
||
>
|
||
<div v-for="item in activeModelStatuses" :key="`${item.kind}-${item.model}`" class="group relative mb-1 break-inside-avoid">
|
||
<!-- 积分已用尽 -->
|
||
<span
|
||
v-if="item.kind === 'credits_exhausted'"
|
||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||
>
|
||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||
{{ t('admin.accounts.status.creditsExhausted') }}
|
||
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
|
||
</span>
|
||
<!-- 正在走积分(模型限流但积分可用)-->
|
||
<span
|
||
v-else-if="item.kind === 'credits_active'"
|
||
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" />
|
||
{{ formatScopeName(item.model) }}
|
||
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
|
||
</span>
|
||
<!-- Tooltip -->
|
||
<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"
|
||
>
|
||
{{
|
||
item.kind === 'credits_exhausted'
|
||
? t('admin.accounts.status.creditsExhaustedUntil', { time: formatTime(item.reset_at) })
|
||
: item.kind === 'credits_active'
|
||
? 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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Overload Indicator (529) -->
|
||
<div v-if="isOverloaded" class="group relative">
|
||
<span
|
||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||
>
|
||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||
529
|
||
</span>
|
||
<!-- Tooltip -->
|
||
<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.overloadedUntil', { time: formatTime(account.overload_until) }) }}
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<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'
|
||
|
||
const { t } = useI18n()
|
||
|
||
const props = defineProps<{
|
||
account: Account
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'show-temp-unsched', account: Account): void
|
||
}>()
|
||
|
||
// Computed: is rate limited (429)
|
||
const isRateLimited = computed(() => {
|
||
if (!props.account.rate_limit_reset_at) return false
|
||
return new Date(props.account.rate_limit_reset_at) > new Date()
|
||
})
|
||
|
||
type AccountModelStatusItem = {
|
||
kind: 'rate_limit' | 'credits_exhausted' | 'credits_active'
|
||
model: string
|
||
reset_at: string
|
||
}
|
||
|
||
// 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
|
||
const now = new Date()
|
||
const items: AccountModelStatusItem[] = []
|
||
|
||
if (!modelLimits) return items
|
||
|
||
// 检查 AICredits key 是否生效(积分是否耗尽)
|
||
const aiCreditsEntry = modelLimits['AICredits']
|
||
const hasActiveAICredits = aiCreditsEntry && new Date(aiCreditsEntry.rate_limit_reset_at) > now
|
||
const allowOverages = !!(extra?.allow_overages)
|
||
|
||
for (const [model, info] of Object.entries(modelLimits)) {
|
||
if (new Date(info.rate_limit_reset_at) <= now) continue
|
||
|
||
if (model === 'AICredits') {
|
||
// AICredits key → 积分已用尽
|
||
items.push({ kind: 'credits_exhausted', model, reset_at: info.rate_limit_reset_at })
|
||
} else if (allowOverages && !hasActiveAICredits) {
|
||
// 普通模型限流 + overages 启用 + 积分可用 → 正在走积分
|
||
items.push({ kind: 'credits_active', model, reset_at: info.rate_limit_reset_at })
|
||
} else {
|
||
// 普通模型限流
|
||
items.push({ kind: 'rate_limit', model, reset_at: info.rate_limit_reset_at })
|
||
}
|
||
}
|
||
|
||
return items
|
||
})
|
||
|
||
const formatScopeName = (scope: string): string => {
|
||
const aliases: Record<string, string> = {
|
||
// Claude 系列
|
||
'claude-opus-4-6': 'COpus46',
|
||
'claude-opus-4-6-thinking': 'COpus46T',
|
||
'claude-sonnet-4-6': 'CSon46',
|
||
'claude-sonnet-4-5': 'CSon45',
|
||
'claude-sonnet-4-5-thinking': 'CSon45T',
|
||
// Gemini 2.5 系列
|
||
'gemini-2.5-flash': 'G25F',
|
||
'gemini-2.5-flash-lite': 'G25FL',
|
||
'gemini-2.5-flash-thinking': 'G25FT',
|
||
'gemini-2.5-pro': 'G25P',
|
||
'gemini-2.5-flash-image': 'G25I',
|
||
// Gemini 3 系列
|
||
'gemini-3-flash': 'G3F',
|
||
'gemini-3.1-pro-high': 'G3PH',
|
||
'gemini-3.1-pro-low': 'G3PL',
|
||
'gemini-3-pro-image': 'G3PI',
|
||
'gemini-3.1-flash-image': 'G31FI',
|
||
// 其他
|
||
'gpt-oss-120b-medium': 'GPT120',
|
||
'tab_flash_lite_preview': 'TabFL',
|
||
// 旧版 scope 别名(兼容)
|
||
claude: 'Claude',
|
||
claude_sonnet: 'CSon',
|
||
claude_opus: 'COpus',
|
||
claude_haiku: 'CHaiku',
|
||
gemini_text: 'Gemini',
|
||
gemini_image: 'GImg',
|
||
gemini_flash: 'GFlash',
|
||
gemini_pro: 'GPro',
|
||
}
|
||
return aliases[scope] || scope
|
||
}
|
||
|
||
const formatModelResetTime = (resetAt: string): string => {
|
||
const date = new Date(resetAt)
|
||
const now = new Date()
|
||
const diffMs = date.getTime() - now.getTime()
|
||
if (diffMs <= 0) return ''
|
||
const totalSecs = Math.floor(diffMs / 1000)
|
||
const h = Math.floor(totalSecs / 3600)
|
||
const m = Math.floor((totalSecs % 3600) / 60)
|
||
const s = totalSecs % 60
|
||
if (h > 0) return `${h}h${m}m`
|
||
if (m > 0) return `${m}m${s}s`
|
||
return `${s}s`
|
||
}
|
||
|
||
// Computed: is overloaded (529)
|
||
const isOverloaded = computed(() => {
|
||
if (!props.account.overload_until) return false
|
||
return new Date(props.account.overload_until) > new Date()
|
||
})
|
||
|
||
// Computed: is temp unschedulable
|
||
const isTempUnschedulable = computed(() => {
|
||
if (!props.account.temp_unschedulable_until) return false
|
||
return new Date(props.account.temp_unschedulable_until) > new Date()
|
||
})
|
||
|
||
// Computed: has error status
|
||
const hasError = computed(() => {
|
||
return props.account.status === 'error'
|
||
})
|
||
|
||
// Computed: countdown text for rate limit (429)
|
||
const rateLimitCountdown = computed(() => {
|
||
return formatCountdown(props.account.rate_limit_reset_at)
|
||
})
|
||
|
||
const rateLimitResumeText = computed(() => {
|
||
if (!rateLimitCountdown.value) return ''
|
||
return t('admin.accounts.status.rateLimitedAutoResume', { time: rateLimitCountdown.value })
|
||
})
|
||
|
||
// Computed: countdown text for overload (529)
|
||
const overloadCountdown = computed(() => {
|
||
return formatCountdownWithSuffix(props.account.overload_until)
|
||
})
|
||
|
||
// Computed: status badge class
|
||
const statusClass = computed(() => {
|
||
if (hasError.value) {
|
||
return 'badge-danger'
|
||
}
|
||
if (isTempUnschedulable.value) {
|
||
return 'badge-warning'
|
||
}
|
||
if (!props.account.schedulable) {
|
||
return 'badge-gray'
|
||
}
|
||
switch (props.account.status) {
|
||
case 'active':
|
||
return 'badge-success'
|
||
case 'inactive':
|
||
return 'badge-gray'
|
||
case 'error':
|
||
return 'badge-danger'
|
||
default:
|
||
return 'badge-gray'
|
||
}
|
||
})
|
||
|
||
// Computed: status text
|
||
const statusText = computed(() => {
|
||
if (hasError.value) {
|
||
return t('admin.accounts.status.error')
|
||
}
|
||
if (isTempUnschedulable.value) {
|
||
return t('admin.accounts.status.tempUnschedulable')
|
||
}
|
||
if (!props.account.schedulable) {
|
||
return t('admin.accounts.status.paused')
|
||
}
|
||
return t(`admin.accounts.status.${props.account.status}`)
|
||
})
|
||
|
||
const handleTempUnschedClick = () => {
|
||
if (!isTempUnschedulable.value) return
|
||
emit('show-temp-unsched', props.account)
|
||
}
|
||
</script>
|