feat: 新增apikey的usage查询页面

This commit is contained in:
shaw
2026-03-05 10:45:51 +08:00
parent 078fefed03
commit d4f6ad7225
5 changed files with 989 additions and 0 deletions

View File

@@ -110,6 +110,65 @@ export default {
}
},
// Key Usage Query Page
keyUsage: {
title: 'API Key Usage',
subtitle: 'Enter your API Key to view real-time spending and usage status',
placeholder: 'sk-ant-mirror-xxxxxxxxxxxx',
query: 'Query',
querying: 'Querying...',
privacyNote: 'Your Key is processed locally in the browser and will not be stored',
dateRange: 'Date Range:',
dateRangeToday: 'Today',
dateRange7d: '7 Days',
dateRange30d: '30 Days',
dateRangeCustom: 'Custom',
apply: 'Apply',
used: 'Used',
detailInfo: 'Detail Information',
tokenStats: 'Token Statistics',
modelStats: 'Model Usage Statistics',
// Table headers
model: 'Model',
requests: 'Requests',
inputTokens: 'Input Tokens',
outputTokens: 'Output Tokens',
totalTokens: 'Total Tokens',
cost: 'Cost',
// Status
quotaMode: 'Key Quota Mode',
walletBalance: 'Wallet Balance',
// Ring card titles
totalQuota: 'Total Quota',
limit5h: '5-Hour Limit',
limitDaily: 'Daily Limit',
limit7d: '7-Day Limit',
limitWeekly: 'Weekly Limit',
limitMonthly: 'Monthly Limit',
// Detail rows
remainingQuota: 'Remaining Quota',
expiresAt: 'Expires At',
todayExpires: '(expires today)',
daysLeft: '({days} days)',
usedQuota: 'Used Quota',
subscriptionType: 'Subscription Type',
subscriptionExpires: 'Subscription Expires',
// Usage stat cells
todayRequests: 'Today Requests',
todayTokens: 'Today Tokens',
todayCost: 'Today Cost',
rpmTpm: 'RPM / TPM',
totalRequests: 'Total Requests',
totalTokensLabel: 'Total Tokens',
totalCost: 'Total Cost',
avgDuration: 'Avg Duration',
// Messages
enterApiKey: 'Please enter an API Key',
querySuccess: 'Query successful',
queryFailed: 'Query failed',
queryFailedRetry: 'Query failed, please try again later',
},
// Setup Wizard
setup: {
title: 'Sub2API Setup',

View File

@@ -110,6 +110,65 @@ export default {
}
},
// Key Usage Query Page
keyUsage: {
title: 'API Key 用量查询',
subtitle: '输入您的 API Key 以查看实时消费金额与使用状态',
placeholder: 'sk-ant-mirror-xxxxxxxxxxxx',
query: '查询',
querying: '查询中...',
privacyNote: '您的 Key 仅在浏览器本地处理,不会被存储',
dateRange: '统计范围:',
dateRangeToday: '今日',
dateRange7d: '7 天',
dateRange30d: '30 天',
dateRangeCustom: '自定义',
apply: '应用',
used: '已使用',
detailInfo: '详细信息',
tokenStats: 'Token 统计',
modelStats: '模型用量统计',
// Table headers
model: '模型',
requests: '请求数',
inputTokens: '输入 Tokens',
outputTokens: '输出 Tokens',
totalTokens: '总 Tokens',
cost: '费用',
// Status
quotaMode: 'Key 限额模式',
walletBalance: '钱包余额',
// Ring card titles
totalQuota: '总额度',
limit5h: '5 小时限额',
limitDaily: '日限额',
limit7d: '7 天限额',
limitWeekly: '周限额',
limitMonthly: '月限额',
// Detail rows
remainingQuota: '剩余额度',
expiresAt: '过期时间',
todayExpires: '(今日到期)',
daysLeft: '({days} 天)',
usedQuota: '已用额度',
subscriptionType: '订阅类型',
subscriptionExpires: '订阅到期',
// Usage stat cells
todayRequests: '今日请求',
todayTokens: '今日 Tokens',
todayCost: '今日费用',
rpmTpm: 'RPM / TPM',
totalRequests: '累计请求',
totalTokensLabel: '累计 Tokens',
totalCost: '累计费用',
avgDuration: '平均耗时',
// Messages
enterApiKey: '请输入 API Key',
querySuccess: '查询成功',
queryFailed: '查询失败',
queryFailedRetry: '查询失败,请稍后重试',
},
// Setup Wizard
setup: {
title: 'Sub2API 安装向导',

View File

@@ -102,6 +102,15 @@ const routes: RouteRecordRaw[] = [
title: 'Reset Password'
}
},
{
path: '/key-usage',
name: 'KeyUsage',
component: () => import('@/views/KeyUsageView.vue'),
meta: {
requiresAuth: false,
title: 'Key Usage',
}
},
// ==================== User Routes ====================
{

View File

@@ -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">
&copy; {{ 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>