|
|
|
@@ -0,0 +1,858 @@
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
|
|
<div class="relative flex min-h-screen flex-col bg-gray-50 dark:bg-dark-950">
|
|
|
|
|
|
|
|
<!-- Header (same pattern as HomeView) -->
|
|
|
|
|
|
|
|
<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 Content -->
|
|
|
|
|
|
|
|
<main class="flex-1 w-full max-w-5xl mx-auto px-6 py-12">
|
|
|
|
|
|
|
|
<!-- Hero -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Input Section -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Date Range Picker -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Results Container -->
|
|
|
|
|
|
|
|
<div v-if="showResults">
|
|
|
|
|
|
|
|
<!-- Loading Skeleton -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Result Content -->
|
|
|
|
|
|
|
|
<div v-else-if="resultData" class="space-y-6">
|
|
|
|
|
|
|
|
<!-- Status Badge -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Ring Cards Grid -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
<!-- Clock icon -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
<!-- Calendar icon -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
<!-- Dollar icon -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Detail Card -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Usage Stats Card -->
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Model Stats Table -->
|
|
|
|
|
|
|
|
<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.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.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 (same pattern as HomeView) -->
|
|
|
|
|
|
|
|
<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, 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Site Settings (same as HomeView) ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Theme (same as HomeView) ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Key Query State ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const apiKey = ref('')
|
|
|
|
|
|
|
|
const keyVisible = ref(false)
|
|
|
|
|
|
|
|
const isQuerying = ref(false)
|
|
|
|
|
|
|
|
const showResults = ref(false)
|
|
|
|
|
|
|
|
const showLoading = ref(false)
|
|
|
|
|
|
|
|
const showDatePicker = ref(false)
|
|
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
|
|
|
const resultData = ref<any>(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Date Range State ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}`
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Ring Animation ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Animate percentage numbers
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Computed Data ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} 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
|
|
|
|
|
|
|
|
rows.push({
|
|
|
|
|
|
|
|
iconBg: 'bg-primary-500/10', iconColor: 'text-primary-500', iconSvg: ICON_DOLLAR,
|
|
|
|
|
|
|
|
label: `${t('keyUsage.usedQuota')} (${windowMap[rl.window] || rl.window})`,
|
|
|
|
|
|
|
|
value: `${usd(rl.used)} / ${usd(rl.limit)}`,
|
|
|
|
|
|
|
|
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.todayTokens'), value: fmtNum(today.total_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.totalTokensLabel'), value: fmtNum(total.total_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` : '-' },
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
|
|
|
const modelStats = computed<any[]>(() => resultData.value?.model_stats || [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Utility Functions ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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' })
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== API Query ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Trigger ring animations after DOM update
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Lifecycle ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
|
|
initTheme()
|
|
|
|
|
|
|
|
if (!appStore.publicSettingsLoaded) {
|
|
|
|
|
|
|
|
appStore.fetchPublicSettings()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
|
|
/* Input focus ring */
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Ring animation */
|
|
|
|
|
|
|
|
.progress-ring {
|
|
|
|
|
|
|
|
transition: stroke-dashoffset 1.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
|
|
|
|
|
transform: rotate(-90deg);
|
|
|
|
|
|
|
|
transform-origin: 50% 50%;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Skeleton loading */
|
|
|
|
|
|
|
|
@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%;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Fade up animation */
|
|
|
|
|
|
|
|
@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; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Pulse dot */
|
|
|
|
|
|
|
|
@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 */
|
|
|
|
|
|
|
|
.tabular-nums {
|
|
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
|
|
letter-spacing: -0.02em;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
</style>
|