| <template> |
| <div class="relative flex min-h-screen flex-col bg-gray-50 dark:bg-dark-950"> |
| |
| <header class="relative z-20 px-6 py-4"> |
| <nav class="mx-auto flex max-w-6xl items-center justify-between"> |
| <router-link to="/home" class="flex items-center gap-3"> |
| <div class="h-10 w-10 overflow-hidden rounded-xl shadow-md"> |
| <img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" /> |
| </div> |
| <span class="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">{{ siteName }}</span> |
| </router-link> |
| <div class="flex items-center gap-3"> |
| <LocaleSwitcher /> |
| <a |
| v-if="docUrl" |
| :href="docUrl" |
| target="_blank" |
| rel="noopener noreferrer" |
| class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white" |
| :title="t('home.viewDocs')" |
| > |
| <Icon name="book" size="md" /> |
| </a> |
| <button |
| @click="toggleTheme" |
| class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white" |
| :title="isDark ? t('home.switchToLight') : t('home.switchToDark')" |
| > |
| <Icon v-if="isDark" name="sun" size="md" /> |
| <Icon v-else name="moon" size="md" /> |
| </button> |
| </div> |
| </nav> |
| </header> |
| |
| |
| <main class="flex-1 w-full max-w-5xl mx-auto px-6 py-12"> |
| |
| <div class="text-center mb-12"> |
| <h1 class="text-3xl sm:text-4xl font-bold tracking-tight mb-3 text-gray-900 dark:text-white"> |
| {{ t('keyUsage.title') }} |
| </h1> |
| <p class="text-gray-500 dark:text-dark-400 text-base max-w-md mx-auto"> |
| {{ t('keyUsage.subtitle') }} |
| </p> |
| </div> |
| |
| |
| <div class="max-w-xl mx-auto mb-14"> |
| <div class="flex gap-3"> |
| <div class="flex-1 relative"> |
| <div class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-dark-500"> |
| <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/> |
| </svg> |
| </div> |
| <input |
| v-model="apiKey" |
| :type="keyVisible ? 'text' : 'password'" |
| :placeholder="t('keyUsage.placeholder')" |
| class="input-ring w-full h-12 pl-12 pr-12 rounded-xl border border-gray-200 bg-white text-sm text-gray-900 placeholder:text-gray-400 transition-all dark:border-dark-700 dark:bg-dark-900 dark:text-white dark:placeholder:text-dark-500" |
| @keydown.enter="queryKey" |
| /> |
| <button |
| @click="keyVisible = !keyVisible" |
| class="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700 dark:text-dark-500 dark:hover:text-white transition-colors" |
| > |
| <svg v-if="!keyVisible" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/> |
| <line x1="1" y1="1" x2="23" y2="23"/> |
| </svg> |
| <svg v-else class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/> |
| </svg> |
| </button> |
| </div> |
| <button |
| @click="queryKey" |
| :disabled="isQuerying" |
| class="h-12 px-7 rounded-xl bg-primary-500 hover:bg-primary-600 text-white font-medium text-sm transition-all active:scale-[0.97] flex items-center gap-2 whitespace-nowrap disabled:opacity-60" |
| > |
| <svg v-if="isQuerying" class="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none"> |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"/> |
| <path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/> |
| </svg> |
| <svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> |
| <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/> |
| </svg> |
| {{ isQuerying ? t('keyUsage.querying') : t('keyUsage.query') }} |
| </button> |
| </div> |
| <p class="text-xs text-gray-400 dark:text-dark-500 mt-3 text-center"> |
| {{ t('keyUsage.privacyNote') }} |
| </p> |
| |
| |
| <div v-if="showDatePicker" class="mt-4"> |
| <div class="flex flex-wrap items-center gap-2 justify-center"> |
| <span class="text-xs text-gray-500 dark:text-dark-400">{{ t('keyUsage.dateRange') }}</span> |
| <button |
| v-for="range in dateRanges" |
| :key="range.key" |
| @click="setDateRange(range.key)" |
| class="text-xs px-3 py-1.5 rounded-lg border transition-all" |
| :class="currentRange === range.key |
| ? 'bg-primary-500 text-white border-primary-500' |
| : 'border-gray-200 bg-white text-gray-700 dark:border-dark-700 dark:bg-dark-900 dark:text-dark-200 hover:border-primary-300 dark:hover:border-dark-600'" |
| >{{ range.label }}</button> |
| <div v-if="currentRange === 'custom'" class="flex items-center gap-2 ml-1"> |
| <input |
| v-model="customStartDate" |
| type="date" |
| class="input-ring text-xs px-2 py-1.5 rounded-lg border border-gray-200 bg-white text-gray-900 dark:border-dark-700 dark:bg-dark-900 dark:text-white" |
| /> |
| <span class="text-xs text-gray-400">-</span> |
| <input |
| v-model="customEndDate" |
| type="date" |
| class="input-ring text-xs px-2 py-1.5 rounded-lg border border-gray-200 bg-white text-gray-900 dark:border-dark-700 dark:bg-dark-900 dark:text-white" |
| /> |
| <button |
| @click="queryKey" |
| class="text-xs px-3 py-1.5 rounded-lg bg-primary-500 text-white hover:bg-primary-600" |
| >{{ t('keyUsage.apply') }}</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-if="showResults"> |
| |
| <div v-if="showLoading" class="space-y-6"> |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> |
| <div class="rounded-2xl border border-gray-200 bg-white p-8 dark:border-dark-700 dark:bg-dark-900"> |
| <div class="skeleton h-5 w-24 mb-6"></div> |
| <div class="flex justify-center"><div class="skeleton w-44 h-44 rounded-full"></div></div> |
| </div> |
| <div class="rounded-2xl border border-gray-200 bg-white p-8 dark:border-dark-700 dark:bg-dark-900"> |
| <div class="skeleton h-5 w-24 mb-6"></div> |
| <div class="flex justify-center"><div class="skeleton w-44 h-44 rounded-full"></div></div> |
| </div> |
| </div> |
| <div class="rounded-2xl border border-gray-200 bg-white p-8 dark:border-dark-700 dark:bg-dark-900"> |
| <div class="skeleton h-5 w-32 mb-6"></div> |
| <div class="space-y-4"> |
| <div class="skeleton h-4 w-full"></div> |
| <div class="skeleton h-4 w-3/4"></div> |
| <div class="skeleton h-4 w-5/6"></div> |
| <div class="skeleton h-4 w-2/3"></div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-else-if="resultData" class="space-y-6"> |
| |
| <div v-if="statusInfo" class="fade-up flex items-center justify-center mb-2"> |
| <div class="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-gray-200 bg-white/90 shadow-sm backdrop-blur-sm dark:border-dark-700 dark:bg-dark-900/90"> |
| <span |
| class="w-2.5 h-2.5 rounded-full pulse-dot" |
| :class="statusInfo.isActive ? 'bg-emerald-500' : 'bg-rose-500'" |
| ></span> |
| <span class="text-sm font-medium text-gray-900 dark:text-white">{{ statusInfo.label }}</span> |
| <span class="text-xs text-gray-400 dark:text-dark-500">|</span> |
| <span class="text-xs text-gray-500 dark:text-dark-400">{{ statusInfo.statusText }}</span> |
| </div> |
| </div> |
| |
| |
| <div v-if="ringItems.length > 0" :class="ringGridClass"> |
| <div |
| v-for="(ring, i) in ringItems" |
| :key="i" |
| class="fade-up rounded-2xl border border-gray-200 bg-white/90 p-8 backdrop-blur-sm transition-all duration-300 hover:shadow-lg dark:border-dark-700 dark:bg-dark-900/90" |
| :class="`fade-up-delay-${Math.min(i + 1, 4)}`" |
| > |
| <div class="flex items-center justify-between mb-6"> |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"> |
| {{ ring.title }} |
| </h3> |
| |
| <svg v-if="ring.iconType === 'clock'" class="w-5 h-5 text-gray-400 dark:text-dark-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/> |
| </svg> |
| |
| <svg v-else-if="ring.iconType === 'calendar'" class="w-5 h-5 text-gray-400 dark:text-dark-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/> |
| </svg> |
| |
| <svg v-else class="w-5 h-5 text-gray-400 dark:text-dark-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/> |
| </svg> |
| </div> |
| <div class="flex justify-center"> |
| <div class="relative"> |
| <svg class="w-44 h-44" viewBox="0 0 160 160"> |
| <circle cx="80" cy="80" r="68" fill="none" :stroke="ringTrackColor" stroke-width="10"/> |
| <circle |
| class="progress-ring" |
| cx="80" cy="80" r="68" fill="none" |
| :stroke="`url(#ring-grad-${i})`" |
| stroke-width="10" stroke-linecap="round" |
| :stroke-dasharray="CIRCUMFERENCE.toFixed(2)" |
| :stroke-dashoffset="getRingOffset(ring)" |
| /> |
| <defs> |
| <linearGradient :id="`ring-grad-${i}`" x1="0%" y1="0%" x2="100%" y2="100%"> |
| <stop offset="0%" :stop-color="RING_GRADIENTS[i % 4].from"/> |
| <stop offset="100%" :stop-color="RING_GRADIENTS[i % 4].to"/> |
| </linearGradient> |
| </defs> |
| </svg> |
| <div class="absolute inset-0 flex flex-col items-center justify-center"> |
| <template v-if="ring.isBalance"> |
| <span class="text-2xl font-bold tabular-nums" :style="{ color: RING_GRADIENTS[i % 4].from }"> |
| {{ ring.amount }} |
| </span> |
| </template> |
| <template v-else> |
| <span class="text-3xl font-bold tabular-nums text-gray-900 dark:text-white"> |
| {{ displayPcts[i] ?? 0 }}% |
| </span> |
| <span class="text-xs text-gray-500 dark:text-dark-400 mt-0.5">{{ t('keyUsage.used') }}</span> |
| <span |
| class="text-sm font-semibold mt-1 tabular-nums" |
| :style="{ color: RING_GRADIENTS[i % 4].from }" |
| >{{ ring.amount }}</span> |
| <p v-if="ring.resetAt && formatResetTime(ring.resetAt)" class="text-xs text-gray-400 dark:text-gray-500 mt-0.5 tabular-nums"> |
| ⟳ {{ formatResetTime(ring.resetAt) }} |
| </p> |
| </template> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div |
| v-if="detailRows.length > 0" |
| class="fade-up fade-up-delay-3 rounded-2xl border border-gray-200 bg-white/90 backdrop-blur-sm overflow-hidden dark:border-dark-700 dark:bg-dark-900/90" |
| > |
| <div class="px-8 py-5 border-b border-gray-200 dark:border-dark-700"> |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.detailInfo') }}</h3> |
| </div> |
| <div class="divide-y divide-gray-100 dark:divide-dark-800"> |
| <div |
| v-for="(row, i) in detailRows" |
| :key="i" |
| class="px-8 py-4 flex items-center justify-between" |
| > |
| <div class="flex items-center gap-3"> |
| <div class="w-8 h-8 rounded-lg flex items-center justify-center" :class="row.iconBg"> |
| <svg |
| class="w-4 h-4" |
| :class="row.iconColor" |
| viewBox="0 0 24 24" fill="none" stroke="currentColor" |
| stroke-width="2" stroke-linecap="round" stroke-linejoin="round" |
| v-html="row.iconSvg" |
| ></svg> |
| </div> |
| <span class="text-sm text-gray-700 dark:text-dark-200">{{ row.label }}</span> |
| </div> |
| <span class="text-sm font-semibold tabular-nums" :class="row.valueClass || 'text-gray-900 dark:text-white'"> |
| {{ row.value }} |
| </span> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div |
| v-if="usageStatCells.length > 0" |
| class="fade-up fade-up-delay-3 rounded-2xl border border-gray-200 bg-white/90 backdrop-blur-sm overflow-hidden dark:border-dark-700 dark:bg-dark-900/90" |
| > |
| <div class="px-8 py-5 border-b border-gray-200 dark:border-dark-700"> |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.tokenStats') }}</h3> |
| </div> |
| <div class="grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-100 dark:bg-dark-800"> |
| <div |
| v-for="(cell, i) in usageStatCells" |
| :key="i" |
| class="bg-white px-6 py-4 dark:bg-dark-900" |
| > |
| <div class="text-xs text-gray-500 dark:text-dark-400 mb-1">{{ cell.label }}</div> |
| <div class="text-sm font-semibold tabular-nums text-gray-900 dark:text-white">{{ cell.value }}</div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div |
| v-if="modelStats.length > 0" |
| class="fade-up fade-up-delay-4 rounded-2xl border border-gray-200 bg-white/90 backdrop-blur-sm overflow-hidden dark:border-dark-700 dark:bg-dark-900/90" |
| > |
| <div class="px-8 py-5 border-b border-gray-200 dark:border-dark-700"> |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.modelStats') }}</h3> |
| </div> |
| <div class="overflow-x-auto"> |
| <table class="w-full"> |
| <thead> |
| <tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-950"> |
| <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.model') }}</th> |
| <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.requests') }}</th> |
| <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.inputTokens') }}</th> |
| <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.outputTokens') }}</th> |
| <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cacheCreationTokens') }}</th> |
| <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cacheReadTokens') }}</th> |
| <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.totalTokens') }}</th> |
| <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cost') }}</th> |
| </tr> |
| </thead> |
| <tbody> |
| <tr |
| v-for="(m, i) in modelStats" |
| :key="i" |
| class="border-b border-gray-100 last:border-b-0 dark:border-dark-800" |
| > |
| <td class="px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white">{{ m.model || '-' }}</td> |
| <td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(m.requests) }}</td> |
| <td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(m.input_tokens) }}</td> |
| <td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(m.output_tokens) }}</td> |
| <td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(m.cache_creation_tokens) }}</td> |
| <td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(m.cache_read_tokens) }}</td> |
| <td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(m.total_tokens) }}</td> |
| <td class="px-4 py-3 text-sm tabular-nums text-right font-medium text-gray-900 dark:text-white">{{ usd(m.actual_cost != null ? m.actual_cost : m.cost) }}</td> |
| </tr> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </div> |
| </main> |
| |
| |
| <footer class="relative z-10 border-t border-gray-200/50 px-6 py-8 dark:border-dark-800/50"> |
| <div class="mx-auto flex max-w-6xl flex-col items-center justify-center gap-4 text-center sm:flex-row sm:text-left"> |
| <p class="text-sm text-gray-500 dark:text-dark-400"> |
| © {{ currentYear }} {{ siteName }}. {{ t('home.footer.allRightsReserved') }} |
| </p> |
| <div class="flex items-center gap-4"> |
| <a |
| v-if="docUrl" |
| :href="docUrl" |
| target="_blank" |
| rel="noopener noreferrer" |
| class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white" |
| >{{ t('home.docs') }}</a> |
| <a |
| :href="githubUrl" |
| target="_blank" |
| rel="noopener noreferrer" |
| class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white" |
| >GitHub</a> |
| </div> |
| </div> |
| </footer> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { useAppStore } from '@/stores' |
| import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' |
| import Icon from '@/components/icons/Icon.vue' |
| |
| const { t, locale } = useI18n() |
| const appStore = useAppStore() |
| |
| |
| |
| const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API') |
| const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '') |
| const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '') |
| const githubUrl = 'https://github.com/Wei-Shaw/sub2api' |
| |
| |
| |
| const isDark = ref(document.documentElement.classList.contains('dark')) |
| |
| function toggleTheme() { |
| isDark.value = !isDark.value |
| document.documentElement.classList.toggle('dark', isDark.value) |
| localStorage.setItem('theme', isDark.value ? 'dark' : 'light') |
| } |
| |
| const currentYear = computed(() => new Date().getFullYear()) |
| |
| |
| |
| const apiKey = ref('') |
| const keyVisible = ref(false) |
| const isQuerying = ref(false) |
| const showResults = ref(false) |
| const showLoading = ref(false) |
| const showDatePicker = ref(false) |
| |
| const resultData = ref<any>(null) |
| const now = ref(new Date()) |
| let resetTimer: ReturnType<typeof setInterval> | null = null |
| |
| |
| |
| type DateRangeKey = 'today' | '7d' | '30d' | 'custom' |
| const currentRange = ref<DateRangeKey>('today') |
| const customStartDate = ref('') |
| const customEndDate = ref('') |
| |
| const dateRanges = computed(() => [ |
| { key: 'today' as const, label: t('keyUsage.dateRangeToday') }, |
| { key: '7d' as const, label: t('keyUsage.dateRange7d') }, |
| { key: '30d' as const, label: t('keyUsage.dateRange30d') }, |
| { key: 'custom' as const, label: t('keyUsage.dateRangeCustom') }, |
| ]) |
| |
| function setDateRange(key: DateRangeKey) { |
| currentRange.value = key |
| if (key !== 'custom') { |
| queryKey() |
| } |
| } |
| |
| function getDateParams(): string { |
| const now = new Date() |
| const fmt = (d: Date) => d.toISOString().split('T')[0] |
| |
| if (currentRange.value === 'custom') { |
| if (customStartDate.value && customEndDate.value) { |
| return `start_date=${customStartDate.value}&end_date=${customEndDate.value}` |
| } |
| return '' |
| } |
| |
| const end = fmt(now) |
| let start: string |
| switch (currentRange.value) { |
| case 'today': start = end; break |
| case '7d': start = fmt(new Date(now.getTime() - 7 * 86400000)); break |
| case '30d': start = fmt(new Date(now.getTime() - 30 * 86400000)); break |
| default: start = fmt(new Date(now.getTime() - 30 * 86400000)) |
| } |
| return `start_date=${start}&end_date=${end}` |
| } |
| |
| |
| |
| const CIRCUMFERENCE = 2 * Math.PI * 68 |
| const RING_GRADIENTS = [ |
| { from: '#14b8a6', to: '#5eead4' }, |
| { from: '#6366F1', to: '#A5B4FC' }, |
| { from: '#10B981', to: '#6EE7B7' }, |
| { from: '#F59E0B', to: '#FCD34D' }, |
| ] |
| |
| const ringAnimated = ref(false) |
| const displayPcts = ref<number[]>([]) |
| |
| const ringTrackColor = computed(() => isDark.value ? '#222222' : '#F0F0EE') |
| |
| interface RingItem { |
| title: string |
| pct: number |
| amount: string |
| isBalance?: boolean |
| iconType: 'clock' | 'calendar' | 'dollar' |
| resetAt?: string | null |
| } |
| |
| function getRingOffset(ring: RingItem): number { |
| if (!ringAnimated.value) return CIRCUMFERENCE |
| if (ring.isBalance) return 0 |
| return CIRCUMFERENCE - (Math.min(ring.pct, 100) / 100) * CIRCUMFERENCE |
| } |
| |
| function triggerRingAnimation(items: RingItem[]) { |
| ringAnimated.value = false |
| displayPcts.value = items.map(() => 0) |
| |
| nextTick(() => { |
| requestAnimationFrame(() => { |
| setTimeout(() => { |
| ringAnimated.value = true |
| |
| |
| const duration = 1000 |
| const startTime = performance.now() |
| const targets = items.map(item => item.isBalance ? 0 : item.pct) |
| |
| function tick() { |
| const elapsed = performance.now() - startTime |
| const p = Math.min(elapsed / duration, 1) |
| const ease = 1 - Math.pow(1 - p, 3) |
| displayPcts.value = targets.map(target => Math.round(ease * target)) |
| if (p < 1) requestAnimationFrame(tick) |
| } |
| requestAnimationFrame(tick) |
| }, 50) |
| }) |
| }) |
| } |
| |
| |
| |
| const statusInfo = computed(() => { |
| const data = resultData.value |
| if (!data) return null |
| |
| if (data.mode === 'quota_limited') { |
| const isValid = data.isValid !== false |
| const statusMap: Record<string, string> = { |
| active: 'Active', |
| quota_exhausted: 'Quota Exhausted', |
| expired: 'Expired', |
| } |
| return { |
| label: t('keyUsage.quotaMode'), |
| statusText: statusMap[data.status] || data.status || 'Unknown', |
| isActive: isValid && data.status === 'active', |
| } |
| } |
| |
| return { |
| label: data.planName || t('keyUsage.walletBalance'), |
| statusText: 'Active', |
| isActive: true, |
| } |
| }) |
| |
| const ringItems = computed<RingItem[]>(() => { |
| const data = resultData.value |
| if (!data) return [] |
| |
| const items: RingItem[] = [] |
| |
| if (data.mode === 'quota_limited') { |
| if (data.quota) { |
| const pct = data.quota.limit > 0 ? Math.min(Math.round((data.quota.used / data.quota.limit) * 100), 100) : 0 |
| items.push({ title: t('keyUsage.totalQuota'), pct, amount: `${usd(data.quota.used)} / ${usd(data.quota.limit)}`, iconType: 'dollar' }) |
| } |
| if (data.rate_limits) { |
| const windowLabels: Record<string, string> = { '5h': t('keyUsage.limit5h'), '1d': t('keyUsage.limitDaily'), '7d': t('keyUsage.limit7d') } |
| const windowIcons: Record<string, 'clock' | 'calendar'> = { '5h': 'clock', '1d': 'calendar', '7d': 'calendar' } |
| for (const rl of data.rate_limits) { |
| const pct = rl.limit > 0 ? Math.min(Math.round((rl.used / rl.limit) * 100), 100) : 0 |
| items.push({ |
| title: windowLabels[rl.window] || rl.window, |
| pct, |
| amount: `${usd(rl.used)} / ${usd(rl.limit)}`, |
| iconType: windowIcons[rl.window] || 'clock', |
| resetAt: rl.reset_at, |
| }) |
| } |
| } |
| } else { |
| if (data.subscription) { |
| const sub = data.subscription |
| const limits = [ |
| { label: t('keyUsage.limitDaily'), usage: sub.daily_usage_usd, limit: sub.daily_limit_usd }, |
| { label: t('keyUsage.limitWeekly'), usage: sub.weekly_usage_usd, limit: sub.weekly_limit_usd }, |
| { label: t('keyUsage.limitMonthly'), usage: sub.monthly_usage_usd, limit: sub.monthly_limit_usd }, |
| ] |
| for (const l of limits) { |
| if (l.limit != null && l.limit > 0) { |
| const pct = Math.min(Math.round((l.usage / l.limit) * 100), 100) |
| items.push({ title: l.label, pct, amount: `${usd(l.usage)} / ${usd(l.limit)}`, iconType: 'calendar' }) |
| } |
| } |
| } |
| if (!data.subscription && data.balance != null) { |
| items.push({ title: t('keyUsage.walletBalance'), pct: 0, amount: usd(data.balance), isBalance: true, iconType: 'dollar' }) |
| } |
| } |
| |
| return items |
| }) |
| |
| const ringGridClass = computed(() => { |
| const len = ringItems.value.length |
| if (len === 1) return 'grid grid-cols-1 max-w-md mx-auto gap-6' |
| if (len === 2) return 'grid grid-cols-1 md:grid-cols-2 gap-6' |
| return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6' |
| }) |
| |
| interface DetailRow { |
| iconBg: string |
| iconColor: string |
| iconSvg: string |
| label: string |
| value: string |
| valueClass: string |
| } |
| |
| function getUsageColor(pct: number): string { |
| if (pct > 90) return 'text-rose-500' |
| if (pct > 70) return 'text-amber-500' |
| return 'text-emerald-500' |
| } |
| |
| const detailRows = computed<DetailRow[]>(() => { |
| const data = resultData.value |
| if (!data) return [] |
| |
| const rows: DetailRow[] = [] |
| const ICON_SHIELD = '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>' |
| const ICON_CALENDAR = '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>' |
| const ICON_DOLLAR = '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' |
| const ICON_CHECK = '<polyline points="20 6 9 17 4 12"/>' |
| |
| if (data.mode === 'quota_limited') { |
| if (data.quota) { |
| const remainColor = data.quota.remaining <= 0 ? 'text-rose-500' |
| : data.quota.remaining < data.quota.limit * 0.1 ? 'text-amber-500' |
| : 'text-emerald-500' |
| rows.push({ |
| iconBg: 'bg-emerald-500/10', iconColor: 'text-emerald-500', iconSvg: ICON_SHIELD, |
| label: t('keyUsage.remainingQuota'), value: usd(data.quota.remaining), valueClass: remainColor, |
| }) |
| } |
| if (data.expires_at) { |
| const daysLeft = data.days_until_expiry |
| let expiryStr = formatDate(data.expires_at) |
| if (daysLeft != null) { |
| expiryStr += daysLeft > 0 ? ` ${t('keyUsage.daysLeft', { days: daysLeft })}` : daysLeft === 0 ? ` ${t('keyUsage.todayExpires')}` : '' |
| } |
| rows.push({ |
| iconBg: 'bg-amber-500/10', iconColor: 'text-amber-500', iconSvg: ICON_CALENDAR, |
| label: t('keyUsage.expiresAt'), value: expiryStr, valueClass: '', |
| }) |
| } |
| if (data.rate_limits) { |
| const windowMap: Record<string, string> = { '5h': '5H', '1d': locale.value === 'zh' ? '日' : 'D', '7d': '7D' } |
| for (const rl of data.rate_limits) { |
| const pct = rl.limit > 0 ? (rl.used / rl.limit) * 100 : 0 |
| let valueStr = `${usd(rl.used)} / ${usd(rl.limit)}` |
| const resetStr = formatResetTime(rl.reset_at) |
| if (resetStr) { |
| valueStr += ` (⟳ ${resetStr})` |
| } |
| rows.push({ |
| iconBg: 'bg-primary-500/10', iconColor: 'text-primary-500', iconSvg: ICON_DOLLAR, |
| label: `${t('keyUsage.usedQuota')} (${windowMap[rl.window] || rl.window})`, |
| value: valueStr, |
| valueClass: getUsageColor(pct), |
| }) |
| } |
| } |
| } else { |
| rows.push({ |
| iconBg: 'bg-emerald-500/10', iconColor: 'text-emerald-500', iconSvg: ICON_CHECK, |
| label: t('keyUsage.subscriptionType'), value: data.planName || t('keyUsage.walletBalance'), valueClass: '', |
| }) |
| |
| if (data.subscription) { |
| const sub = data.subscription |
| if (sub.daily_limit_usd > 0) { |
| const pct = (sub.daily_usage_usd / sub.daily_limit_usd) * 100 |
| rows.push({ |
| iconBg: 'bg-primary-500/10', iconColor: 'text-primary-500', iconSvg: ICON_DOLLAR, |
| label: `${t('keyUsage.usedQuota')} (${locale.value === 'zh' ? '日' : 'D'})`, value: `${usd(sub.daily_usage_usd)} / ${usd(sub.daily_limit_usd)}`, valueClass: getUsageColor(pct), |
| }) |
| } |
| if (sub.weekly_limit_usd > 0) { |
| const pct = (sub.weekly_usage_usd / sub.weekly_limit_usd) * 100 |
| rows.push({ |
| iconBg: 'bg-indigo-500/10', iconColor: 'text-indigo-500', iconSvg: ICON_DOLLAR, |
| label: `${t('keyUsage.usedQuota')} (${locale.value === 'zh' ? '周' : 'W'})`, value: `${usd(sub.weekly_usage_usd)} / ${usd(sub.weekly_limit_usd)}`, valueClass: getUsageColor(pct), |
| }) |
| } |
| if (sub.monthly_limit_usd > 0) { |
| const pct = (sub.monthly_usage_usd / sub.monthly_limit_usd) * 100 |
| rows.push({ |
| iconBg: 'bg-emerald-500/10', iconColor: 'text-emerald-500', iconSvg: ICON_DOLLAR, |
| label: `${t('keyUsage.usedQuota')} (${locale.value === 'zh' ? '月' : 'M'})`, value: `${usd(sub.monthly_usage_usd)} / ${usd(sub.monthly_limit_usd)}`, valueClass: getUsageColor(pct), |
| }) |
| } |
| if (sub.expires_at) { |
| rows.push({ |
| iconBg: 'bg-amber-500/10', iconColor: 'text-amber-500', iconSvg: ICON_CALENDAR, |
| label: t('keyUsage.subscriptionExpires'), value: formatDate(sub.expires_at), valueClass: '', |
| }) |
| } |
| } |
| |
| const remainColor = data.remaining != null |
| ? (data.remaining <= 0 ? 'text-rose-500' : data.remaining < 10 ? 'text-amber-500' : 'text-emerald-500') |
| : '' |
| rows.push({ |
| iconBg: 'bg-emerald-500/10', iconColor: 'text-emerald-500', iconSvg: ICON_SHIELD, |
| label: t('keyUsage.remainingQuota'), value: data.remaining != null ? usd(data.remaining) : '-', valueClass: remainColor, |
| }) |
| } |
| |
| return rows |
| }) |
| |
| interface StatCell { |
| label: string |
| value: string |
| } |
| |
| const usageStatCells = computed<StatCell[]>(() => { |
| const usage = resultData.value?.usage |
| if (!usage) return [] |
| |
| const today = usage.today || {} |
| const total = usage.total || {} |
| |
| return [ |
| { label: t('keyUsage.todayRequests'), value: fmtNum(today.requests) }, |
| { label: t('keyUsage.todayInputTokens'), value: fmtNum(today.input_tokens) }, |
| { label: t('keyUsage.todayOutputTokens'), value: fmtNum(today.output_tokens) }, |
| { label: t('keyUsage.todayTokens'), value: fmtNum(today.total_tokens) }, |
| { label: t('keyUsage.todayCacheCreation'), value: fmtNum(today.cache_creation_tokens) }, |
| { label: t('keyUsage.todayCacheRead'), value: fmtNum(today.cache_read_tokens) }, |
| { label: t('keyUsage.todayCost'), value: usd(today.actual_cost) }, |
| { label: t('keyUsage.rpmTpm'), value: `${usage.rpm || 0} / ${usage.tpm || 0}` }, |
| { label: t('keyUsage.totalRequests'), value: fmtNum(total.requests) }, |
| { label: t('keyUsage.totalInputTokens'), value: fmtNum(total.input_tokens) }, |
| { label: t('keyUsage.totalOutputTokens'), value: fmtNum(total.output_tokens) }, |
| { label: t('keyUsage.totalTokensLabel'), value: fmtNum(total.total_tokens) }, |
| { label: t('keyUsage.totalCacheCreation'), value: fmtNum(total.cache_creation_tokens) }, |
| { label: t('keyUsage.totalCacheRead'), value: fmtNum(total.cache_read_tokens) }, |
| { label: t('keyUsage.totalCost'), value: usd(total.actual_cost) }, |
| { label: t('keyUsage.avgDuration'), value: usage.average_duration_ms ? `${Math.round(usage.average_duration_ms)} ms` : '-' }, |
| ] |
| }) |
| |
| |
| const modelStats = computed<any[]>(() => resultData.value?.model_stats || []) |
| |
| |
| |
| function usd(value: number | null | undefined): string { |
| if (value == null || value < 0) return '-' |
| return '$' + Number(value).toFixed(2) |
| } |
| |
| function fmtNum(val: number | null | undefined): string { |
| if (val == null) return '-' |
| return val.toLocaleString() |
| } |
| |
| function formatDate(iso: string | null | undefined): string { |
| if (!iso) return '-' |
| const d = new Date(iso) |
| const loc = locale.value === 'zh' ? 'zh-CN' : 'en-US' |
| return d.toLocaleDateString(loc, { year: 'numeric', month: 'long', day: 'numeric' }) |
| } |
| |
| |
| |
| async function fetchUsage(key: string) { |
| const dateParams = getDateParams() |
| const url = '/v1/usage' + (dateParams ? '?' + dateParams : '') |
| const res = await fetch(url, { |
| headers: { 'Authorization': 'Bearer ' + key }, |
| }) |
| if (!res.ok) { |
| const body = await res.json().catch(() => null) |
| const msg = body?.error?.message || body?.message || `${t('keyUsage.queryFailed')} (${res.status})` |
| throw new Error(msg) |
| } |
| return await res.json() |
| } |
| |
| async function queryKey() { |
| if (isQuerying.value) return |
| const key = apiKey.value.trim() |
| if (!key) { |
| appStore.showInfo(t('keyUsage.enterApiKey')) |
| return |
| } |
| |
| isQuerying.value = true |
| showResults.value = true |
| showLoading.value = true |
| resultData.value = null |
| |
| try { |
| const data = await fetchUsage(key) |
| resultData.value = data |
| showLoading.value = false |
| showDatePicker.value = true |
| |
| |
| nextTick(() => { |
| triggerRingAnimation(ringItems.value) |
| }) |
| |
| appStore.showSuccess(t('keyUsage.querySuccess')) |
| } catch (err) { |
| showResults.value = false |
| showLoading.value = false |
| appStore.showError((err as Error).message || t('keyUsage.queryFailedRetry')) |
| } finally { |
| isQuerying.value = false |
| } |
| } |
| |
| |
| |
| function initTheme() { |
| const savedTheme = localStorage.getItem('theme') |
| if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { |
| isDark.value = true |
| document.documentElement.classList.add('dark') |
| } |
| } |
| |
| function formatResetTime(resetAt: string | null | undefined): string { |
| if (!resetAt) return '' |
| const diff = new Date(resetAt).getTime() - now.value.getTime() |
| if (diff <= 0) return t('keyUsage.resetNow') |
| const days = Math.floor(diff / 86400000) |
| const hours = Math.floor((diff % 86400000) / 3600000) |
| const mins = Math.floor((diff % 3600000) / 60000) |
| if (days > 0) return `${days}d ${hours}h` |
| if (hours > 0) return `${hours}h ${mins}m` |
| return `${mins}m` |
| } |
| |
| onMounted(() => { |
| initTheme() |
| if (!appStore.publicSettingsLoaded) { |
| appStore.fetchPublicSettings() |
| } |
| resetTimer = setInterval(() => { now.value = new Date() }, 60000) |
| }) |
| |
| onUnmounted(() => { |
| if (resetTimer) clearInterval(resetTimer) |
| }) |
| </script> |
| |
| <style scoped> |
| |
| .input-ring { |
| transition: box-shadow 0.2s ease, border-color 0.2s ease; |
| } |
| .input-ring:focus { |
| box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.2); |
| border-color: #14b8a6; |
| outline: none; |
| } |
| |
| |
| .progress-ring { |
| transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1); |
| transform: rotate(-90deg); |
| transform-origin: 50% 50%; |
| } |
| |
| |
| @keyframes shimmer-kv { |
| 0% { background-position: -200% 0; } |
| 100% { background-position: 200% 0; } |
| } |
| .skeleton { |
| background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); |
| background-size: 200% 100%; |
| animation: shimmer-kv 1.8s ease-in-out infinite; |
| border-radius: 8px; |
| } |
| :global(.dark) .skeleton { |
| background: linear-gradient(90deg, #334155 25%, #1e293b 50%, #334155 75%); |
| background-size: 200% 100%; |
| } |
| |
| |
| @keyframes fade-up-kv { |
| from { opacity: 0; transform: translateY(16px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .fade-up { |
| animation: fade-up-kv 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; |
| } |
| .fade-up-delay-1 { animation-delay: 0.1s; opacity: 0; } |
| .fade-up-delay-2 { animation-delay: 0.2s; opacity: 0; } |
| .fade-up-delay-3 { animation-delay: 0.3s; opacity: 0; } |
| .fade-up-delay-4 { animation-delay: 0.4s; opacity: 0; } |
| |
| |
| @keyframes pulse-dot-kv { |
| 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 currentColor; } |
| 50% { opacity: 0.6; box-shadow: 0 0 8px 2px currentColor; } |
| } |
| .pulse-dot { |
| animation: pulse-dot-kv 2s ease-in-out infinite; |
| } |
| |
| |
| .tabular-nums { |
| font-variant-numeric: tabular-nums; |
| letter-spacing: -0.02em; |
| } |
| </style> |
| |