230 lines
8.4 KiB
Vue
230 lines
8.4 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">{{ rateLimitCountdown }}</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 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
|
>
|
|
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(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>
|
|
|
|
<!-- Scope Rate Limit Indicators (Antigravity) -->
|
|
<template v-if="activeScopeRateLimits.length > 0">
|
|
<div v-for="item in activeScopeRateLimits" :key="item.scope" class="group relative">
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded bg-orange-100 px-1.5 py-0.5 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
|
>
|
|
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
|
{{ formatScopeName(item.scope) }}
|
|
</span>
|
|
<!-- Tooltip -->
|
|
<div
|
|
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
|
>
|
|
{{ t('admin.accounts.status.scopeRateLimitedUntil', { scope: formatScopeName(item.scope), 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>
|
|
</template>
|
|
|
|
<!-- 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 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs 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 type { Account } from '@/types'
|
|
import { 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()
|
|
})
|
|
|
|
// Computed: active scope rate limits (Antigravity)
|
|
const activeScopeRateLimits = computed(() => {
|
|
const scopeLimits = props.account.scope_rate_limits
|
|
if (!scopeLimits) return []
|
|
const now = new Date()
|
|
return Object.entries(scopeLimits)
|
|
.filter(([, info]) => new Date(info.reset_at) > now)
|
|
.map(([scope, info]) => ({ scope, reset_at: info.reset_at }))
|
|
})
|
|
|
|
const formatScopeName = (scope: string): string => {
|
|
const names: Record<string, string> = {
|
|
claude: 'Claude',
|
|
gemini_text: 'Gemini',
|
|
gemini_image: 'Image'
|
|
}
|
|
return names[scope] || scope
|
|
}
|
|
|
|
// 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 formatCountdownWithSuffix(props.account.rate_limit_reset_at)
|
|
})
|
|
|
|
// 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>
|