feat: 新增apikey的usage查询页面
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 安装向导',
|
||||
|
||||
@@ -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 ====================
|
||||
{
|
||||
|
||||
858
frontend/src/views/KeyUsageView.vue
Normal file
858
frontend/src/views/KeyUsageView.vue
Normal 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">
|
||||
© {{ 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>
|
||||
Reference in New Issue
Block a user