# Conflicts:
#	web/admin-spa/dist/assets/LoginView-BJ0LLv16.js
#	web/admin-spa/dist/assets/LogoTitle-DHj-MjwS.js
#	web/admin-spa/dist/assets/MainLayout-CLydIeqJ.js
#	web/admin-spa/dist/assets/SettingsView-DicW12bL.js
#	web/admin-spa/dist/assets/index-HYE9xPuR.js
#	web/admin-spa/dist/index.html
#	web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
#	web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
#	web/admin-spa/src/views/ApiKeysView.vue
This commit is contained in:
KevinLiao
2025-07-30 20:41:10 +08:00
49 changed files with 4461 additions and 2427 deletions

View File

@@ -3,199 +3,355 @@
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
<p class="text-gray-600">管理您的 Claude Gemini 账户及代理配置</p>
<h3 class="text-xl font-bold text-gray-900 mb-2">
账户管理
</h3>
<p class="text-gray-600">
管理您的 Claude Gemini 账户及代理配置
</p>
</div>
<div class="flex gap-2">
<select v-model="accountSortBy" @change="sortAccounts()" class="form-input px-3 py-2 text-sm">
<option value="name">按名称排序</option>
<option value="dailyTokens">按今日Token排序</option>
<option value="dailyRequests">按今日请求数排序</option>
<option value="totalTokens">按总Token排序</option>
<option value="lastUsed">按最后使用排序</option>
<select
v-model="accountSortBy"
class="form-input px-3 py-2 text-sm"
@change="sortAccounts()"
>
<option value="name">
按名称排序
</option>
<option value="dailyTokens">
按今日Token排序
</option>
<option value="dailyRequests">
按今日请求数排序
</option>
<option value="totalTokens">
按总Token排序
</option>
<option value="lastUsed">
按最后使用排序
</option>
</select>
<button
@click.stop="openCreateAccountModal"
class="btn btn-success px-6 py-3 flex items-center gap-2"
@click.stop="openCreateAccountModal"
>
<i class="fas fa-plus"></i>添加账户
<i class="fas fa-plus" />添加账户
</button>
</div>
</div>
<div v-if="accountsLoading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载账户...</p>
<div
v-if="accountsLoading"
class="text-center py-12"
>
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500">
正在加载账户...
</p>
</div>
<div v-else-if="sortedAccounts.length === 0" class="text-center py-12">
<div
v-else-if="sortedAccounts.length === 0"
class="text-center py-12"
>
<div class="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<i class="fas fa-user-circle text-gray-400 text-xl"></i>
<i class="fas fa-user-circle text-gray-400 text-xl" />
</div>
<p class="text-gray-500 text-lg">暂无账户</p>
<p class="text-gray-400 text-sm mt-2">点击上方按钮添加您的第一个账户</p>
<p class="text-gray-500 text-lg">
暂无账户
</p>
<p class="text-gray-400 text-sm mt-2">
点击上方按钮添加您的第一个账户
</p>
</div>
<div v-else class="table-container">
<div
v-else
class="table-container"
>
<table class="min-w-full">
<thead class="bg-gray-50/80 backdrop-blur-sm">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('name')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('name')"
>
名称
<i v-if="accountsSortBy === 'name'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'name'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('platform')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('platform')"
>
平台
<i v-if="accountsSortBy === 'platform'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'platform'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('accountType')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('accountType')"
>
类型
<i v-if="accountsSortBy === 'accountType'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'accountType'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('status')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('status')"
>
状态
<i v-if="accountsSortBy === 'status'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'status'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('priority')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('priority')"
>
优先级
<i v-if="accountsSortBy === 'priority'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'priority'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
代理
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
今日使用
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
会话窗口
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
最后使用
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
操作
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">今日使用</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">会话窗口</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">最后使用</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50">
<tr v-for="account in sortedAccounts" :key="account.id" class="table-row">
<tr
v-for="account in sortedAccounts"
:key="account.id"
class="table-row"
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-user-circle text-white text-xs"></i>
<i class="fas fa-user-circle text-white text-xs" />
</div>
<div>
<div class="flex items-center gap-2">
<div class="text-sm font-semibold text-gray-900">{{ account.name }}</div>
<span v-if="account.accountType === 'dedicated'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<i class="fas fa-lock mr-1"></i>专属
<div class="text-sm font-semibold text-gray-900">
{{ account.name }}
</div>
<span
v-if="account.accountType === 'dedicated'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
>
<i class="fas fa-lock mr-1" />专属
</span>
<span v-else
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<i class="fas fa-share-alt mr-1"></i>共享
<span
v-else
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
<i class="fas fa-share-alt mr-1" />共享
</span>
</div>
<div class="text-xs text-gray-500">{{ account.id }}</div>
<div class="text-xs text-gray-500">
{{ account.id }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="account.platform === 'gemini'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
<i class="fas fa-robot mr-1"></i>Gemini
<span
v-if="account.platform === 'gemini'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800"
>
<i class="fas fa-robot mr-1" />Gemini
</span>
<span v-else-if="account.platform === 'claude-console'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800">
<i class="fas fa-terminal mr-1"></i>Claude Console
<span
v-else-if="account.platform === 'claude-console'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800"
>
<i class="fas fa-terminal mr-1" />Claude Console
</span>
<span v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-800">
<i class="fas fa-brain mr-1"></i>Claude
<span
v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-800"
>
<i class="fas fa-brain mr-1" />Claude
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="account.platform === 'claude-console'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800">
<i class="fas fa-key mr-1"></i>API Key
<span
v-if="account.platform === 'claude-console'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800"
>
<i class="fas fa-key mr-1" />API Key
</span>
<span v-else-if="account.scopes && account.scopes.length > 0"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
<i class="fas fa-lock mr-1"></i>OAuth
<span
v-else-if="account.scopes && account.scopes.length > 0"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
>
<i class="fas fa-lock mr-1" />OAuth
</span>
<span v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800">
<i class="fas fa-key mr-1"></i>传统
<span
v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800"
>
<i class="fas fa-key mr-1" />传统
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-col gap-1">
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
account.status === 'blocked' ? 'bg-orange-100 text-orange-800' :
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
<div :class="['w-2 h-2 rounded-full mr-2',
<span
:class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
account.status === 'blocked' ? 'bg-orange-100 text-orange-800' :
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']"
>
<div
:class="['w-2 h-2 rounded-full mr-2',
account.status === 'blocked' ? 'bg-orange-500' :
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
account.isActive ? 'bg-green-500' : 'bg-red-500']"
/>
{{ account.status === 'blocked' ? '已封锁' : account.isActive ? '正常' : '异常' }}
</span>
<span v-if="account.rateLimitStatus && account.rateLimitStatus.isRateLimited"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
<i class="fas fa-exclamation-triangle mr-1"></i>
<span
v-if="account.rateLimitStatus && account.rateLimitStatus.isRateLimited"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800"
>
<i class="fas fa-exclamation-triangle mr-1" />
限流中 ({{ account.rateLimitStatus.minutesRemaining }}分钟)
</span>
<span v-if="account.schedulable === false"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700">
<i class="fas fa-pause-circle mr-1"></i>
<span
v-if="account.schedulable === false"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700"
>
<i class="fas fa-pause-circle mr-1" />
不可调度
</span>
<span v-if="account.status === 'blocked' && account.errorMessage"
class="text-xs text-gray-500 mt-1 max-w-xs truncate"
:title="account.errorMessage">
<span
v-if="account.status === 'blocked' && account.errorMessage"
class="text-xs text-gray-500 mt-1 max-w-xs truncate"
:title="account.errorMessage"
>
{{ account.errorMessage }}
</span>
<span v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500">
<span
v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500"
>
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div v-if="account.platform === 'claude' || account.platform === 'claude-console'" class="flex items-center gap-2">
<div
v-if="account.platform === 'claude' || account.platform === 'claude-console'"
class="flex items-center gap-2"
>
<div class="w-16 bg-gray-200 rounded-full h-2">
<div class="bg-gradient-to-r from-green-500 to-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: ((101 - (account.priority || 50)) + '%') }"></div>
<div
class="bg-gradient-to-r from-green-500 to-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: ((101 - (account.priority || 50)) + '%') }"
/>
</div>
<span class="text-xs text-gray-700 font-medium min-w-[20px]">
{{ account.priority || 50 }}
</span>
</div>
<div v-else class="text-gray-400 text-sm">
<div
v-else
class="text-gray-400 text-sm"
>
<span class="text-xs">N/A</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div v-if="formatProxyDisplay(account.proxy)" class="text-xs bg-blue-50 px-2 py-1 rounded font-mono">
<div
v-if="formatProxyDisplay(account.proxy)"
class="text-xs bg-blue-50 px-2 py-1 rounded font-mono"
>
{{ formatProxyDisplay(account.proxy) }}
</div>
<div v-else class="text-gray-400">无代理</div>
<div
v-else
class="text-gray-400"
>
无代理
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1">
<div
v-if="account.usage && account.usage.daily"
class="space-y-1"
>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<div class="w-2 h-2 bg-green-500 rounded-full" />
<span class="text-sm font-medium text-gray-900">{{ account.usage.daily.requests || 0 }} </span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<div class="w-2 h-2 bg-blue-500 rounded-full" />
<span class="text-xs text-gray-600">{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span>
</div>
<div v-if="account.usage.averages && account.usage.averages.rpm > 0" class="text-xs text-gray-500">
<div
v-if="account.usage.averages && account.usage.averages.rpm > 0"
class="text-xs text-gray-500"
>
平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM
</div>
</div>
<div v-else class="text-gray-400 text-xs">暂无数据</div>
<div
v-else
class="text-gray-400 text-xs"
>
暂无数据
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div v-if="account.platform === 'claude' && account.sessionWindow && account.sessionWindow.hasActiveWindow" class="space-y-2">
<div
v-if="account.platform === 'claude' && account.sessionWindow && account.sessionWindow.hasActiveWindow"
class="space-y-2"
>
<div class="flex items-center gap-2">
<div class="w-24 bg-gray-200 rounded-full h-2">
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 h-2 rounded-full transition-all duration-300"
:style="{ width: account.sessionWindow.progress + '%' }"></div>
<div
class="bg-gradient-to-r from-blue-500 to-indigo-600 h-2 rounded-full transition-all duration-300"
:style="{ width: account.sessionWindow.progress + '%' }"
/>
</div>
<span class="text-xs text-gray-700 font-medium min-w-[32px]">
{{ account.sessionWindow.progress }}%
@@ -203,15 +359,24 @@
</div>
<div class="text-xs text-gray-600">
<div>{{ formatSessionWindow(account.sessionWindow.windowStart, account.sessionWindow.windowEnd) }}</div>
<div v-if="account.sessionWindow.remainingTime > 0" class="text-indigo-600 font-medium">
<div
v-if="account.sessionWindow.remainingTime > 0"
class="text-indigo-600 font-medium"
>
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
</div>
</div>
</div>
<div v-else-if="account.platform === 'claude'" class="text-gray-400 text-sm">
<i class="fas fa-minus"></i>
<div
v-else-if="account.platform === 'claude'"
class="text-gray-400 text-sm"
>
<i class="fas fa-minus" />
</div>
<div v-else class="text-gray-400 text-sm">
<div
v-else
class="text-gray-400 text-sm"
>
<span class="text-xs">N/A</span>
</div>
</td>
@@ -222,7 +387,6 @@
<div class="flex items-center gap-2">
<button
v-if="account.platform === 'claude' && account.scopes"
@click="refreshToken(account)"
:disabled="account.isRefreshing"
:class="[
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
@@ -231,41 +395,46 @@
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
]"
:title="account.isRefreshing ? '刷新中...' : '刷新Token'"
@click="refreshToken(account)"
>
<i :class="[
'fas fa-sync-alt',
account.isRefreshing ? 'animate-spin' : ''
]"></i>
<i
:class="[
'fas fa-sync-alt',
account.isRefreshing ? 'animate-spin' : ''
]"
/>
</button>
<button
@click="toggleSchedulable(account)"
:disabled="account.isTogglingSchedulable"
:class="[
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
account.isTogglingSchedulable
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: account.schedulable
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
:title="account.schedulable ? '点击禁用调度' : '点击启用调度'"
@click="toggleSchedulable(account)"
>
<i :class="[
'fas',
account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off'
]"></i>
<i
:class="[
'fas',
account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off'
]"
/>
</button>
<button
@click="editAccount(account)"
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-xs font-medium hover:bg-blue-200 transition-colors"
@click="editAccount(account)"
>
<i class="fas fa-edit"></i>
<i class="fas fa-edit" />
</button>
<button
@click="deleteAccount(account)"
class="px-3 py-1.5 bg-red-100 text-red-700 rounded-lg text-xs font-medium hover:bg-red-200 transition-colors"
@click="deleteAccount(account)"
>
<i class="fas fa-trash"></i>
<i class="fas fa-trash" />
</button>
</div>
</td>

View File

@@ -3,107 +3,200 @@
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3>
<p class="text-gray-600">管理和监控您的 API 密钥</p>
<h3 class="text-xl font-bold text-gray-900 mb-2">
API Keys 管理
</h3>
<p class="text-gray-600">
管理和监控您的 API 密钥
</p>
</div>
<div class="flex items-center gap-3">
<!-- Token统计时间范围选择 -->
<select
v-model="apiKeyStatsTimeRange"
@change="loadApiKeys()"
class="form-input px-3 py-2 text-sm"
@change="loadApiKeys()"
>
<option value="today">今日</option>
<option value="7days">最近7天</option>
<option value="monthly">本月</option>
<option value="all">全部时间</option>
<option value="today">
今日
</option>
<option value="7days">
最近7天
</option>
<option value="monthly">
本月
</option>
<option value="all">
全部时间
</option>
</select>
<!-- 标签筛选器 -->
<select
v-model="selectedTagFilter"
class="form-input px-3 py-2 text-sm"
>
<option value="">所有标签</option>
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
<option value="">
所有标签
</option>
<option
v-for="tag in availableTags"
:key="tag"
:value="tag"
>
{{ tag }}
</option>
</select>
<button
@click.stop="openCreateApiKeyModal"
class="btn btn-primary px-6 py-3 flex items-center gap-2"
@click.stop="openCreateApiKeyModal"
>
<i class="fas fa-plus"></i>创建新 Key
<i class="fas fa-plus" />创建新 Key
</button>
</div>
</div>
<div v-if="apiKeysLoading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载 API Keys...</p>
<div
v-if="apiKeysLoading"
class="text-center py-12"
>
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500">
正在加载 API Keys...
</p>
</div>
<div v-else-if="apiKeys.length === 0" class="text-center py-12">
<div
v-else-if="apiKeys.length === 0"
class="text-center py-12"
>
<div class="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<i class="fas fa-key text-gray-400 text-xl"></i>
<i class="fas fa-key text-gray-400 text-xl" />
</div>
<p class="text-gray-500 text-lg">暂无 API Keys</p>
<p class="text-gray-400 text-sm mt-2">点击上方按钮创建您的第一个 API Key</p>
<p class="text-gray-500 text-lg">
暂无 API Keys
</p>
<p class="text-gray-400 text-sm mt-2">
点击上方按钮创建您的第一个 API Key
</p>
</div>
<div v-else class="table-container">
<div
v-else
class="table-container"
>
<table class="min-w-full">
<thead class="bg-gray-50/80 backdrop-blur-sm">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('name')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortApiKeys('name')"
>
名称
<i v-if="apiKeysSortBy === 'name'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="apiKeysSortBy === 'name'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">标签</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('status')">
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
标签
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
API Key
</th>
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortApiKeys('status')"
>
状态
<i v-if="apiKeysSortBy === 'status'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="apiKeysSortBy === 'status'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
使用统计
<span class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded" @click="sortApiKeys('cost')">
<span
class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded"
@click="sortApiKeys('cost')"
>
(费用
<i v-if="apiKeysSortBy === 'cost'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>)
<i
v-if="apiKeysSortBy === 'cost'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>)
</span>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('createdAt')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortApiKeys('createdAt')"
>
创建时间
<i v-if="apiKeysSortBy === 'createdAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="apiKeysSortBy === 'createdAt'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('expiresAt')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortApiKeys('expiresAt')"
>
过期时间
<i v-if="apiKeysSortBy === 'expiresAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="apiKeysSortBy === 'expiresAt'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
操作
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50">
<template v-for="key in sortedApiKeys" :key="key.id">
<template
v-for="key in sortedApiKeys"
:key="key.id"
>
<!-- API Key 主行 -->
<tr class="table-row">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-key text-white text-xs"></i>
<i class="fas fa-key text-white text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-gray-900">{{ key.name }}</div>
<div class="text-xs text-gray-500">{{ key.id }}</div>
<div class="text-sm font-semibold text-gray-900">
{{ key.name }}
</div>
<div class="text-xs text-gray-500">
{{ key.id }}
</div>
<div class="text-xs text-gray-500 mt-1">
<span v-if="key.claudeAccountId || key.claudeConsoleAccountId">
<i class="fas fa-link mr-1"></i>
<i class="fas fa-link mr-1" />
绑定: {{ getBoundAccountName(key.claudeAccountId, key.claudeConsoleAccountId) }}
</span>
<span v-else>
<i class="fas fa-share-alt mr-1"></i>
<i class="fas fa-share-alt mr-1" />
使用共享池
</span>
</div>
@@ -112,12 +205,17 @@
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
<span v-for="tag in (key.tags || [])" :key="tag"
class="inline-flex items-center px-2 py-0.5 bg-blue-100 text-blue-800 text-xs rounded-full">
<span
v-for="tag in (key.tags || [])"
:key="tag"
class="inline-flex items-center px-2 py-0.5 bg-blue-100 text-blue-800 text-xs rounded-full"
>
{{ tag }}
</span>
<span v-if="!key.tags || key.tags.length === 0"
class="text-xs text-gray-400">无标签</span>
<span
v-if="!key.tags || key.tags.length === 0"
class="text-xs text-gray-400"
>无标签</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@@ -126,10 +224,14 @@
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
<div :class="['w-2 h-2 rounded-full mr-2',
key.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
<span
:class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']"
>
<div
:class="['w-2 h-2 rounded-full mr-2',
key.isActive ? 'bg-green-500' : 'bg-red-500']"
/>
{{ key.isActive ? '活跃' : '禁用' }}
</span>
</td>
@@ -151,7 +253,10 @@
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
</div>
<!-- 每日费用限制 -->
<div v-if="key.dailyCostLimit > 0" class="flex justify-between text-sm">
<div
v-if="key.dailyCostLimit > 0"
class="flex justify-between text-sm"
>
<span class="text-gray-600">今日费用:</span>
<span :class="['font-medium', (key.dailyCost || 0) >= key.dailyCostLimit ? 'text-red-600' : 'text-blue-600']">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
@@ -167,16 +272,25 @@
<span class="text-gray-600">当前并发:</span>
<span :class="['font-medium', key.currentConcurrency > 0 ? 'text-orange-600' : 'text-gray-600']">
{{ key.currentConcurrency || 0 }}
<span v-if="key.concurrencyLimit > 0" class="text-xs text-gray-500">/ {{ key.concurrencyLimit }}</span>
<span
v-if="key.concurrencyLimit > 0"
class="text-xs text-gray-500"
>/ {{ key.concurrencyLimit }}</span>
</span>
</div>
<!-- 时间窗口限流 -->
<div v-if="key.rateLimitWindow > 0" class="flex justify-between text-sm">
<div
v-if="key.rateLimitWindow > 0"
class="flex justify-between text-sm"
>
<span class="text-gray-600">时间窗口:</span>
<span class="font-medium text-indigo-600">{{ key.rateLimitWindow }} 分钟</span>
</div>
<!-- 请求次数限制 -->
<div v-if="key.rateLimitRequests > 0" class="flex justify-between text-sm">
<div
v-if="key.rateLimitRequests > 0"
class="flex justify-between text-sm"
>
<span class="text-gray-600">请求限制:</span>
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} /窗口</span>
</div>
@@ -186,7 +300,10 @@
<span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
</div>
<!-- 缓存Token细节 -->
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0" class="flex justify-between text-xs text-orange-500">
<div
v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0"
class="flex justify-between text-xs text-orange-500"
>
<span>缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
<span>缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
</div>
@@ -204,8 +321,12 @@
</div>
<!-- 模型分布按钮 -->
<div class="pt-2">
<button @click="toggleApiKeyModelStats(key.id)" v-if="key && key.id" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']"></i>
<button
v-if="key && key.id"
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
@click="toggleApiKeyModelStats(key.id)"
>
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']" />
模型使用分布
</button>
</div>
@@ -216,50 +337,62 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="key.expiresAt">
<div v-if="isApiKeyExpired(key.expiresAt)" class="text-red-600">
<i class="fas fa-exclamation-circle mr-1"></i>
<div
v-if="isApiKeyExpired(key.expiresAt)"
class="text-red-600"
>
<i class="fas fa-exclamation-circle mr-1" />
已过期
</div>
<div v-else-if="isApiKeyExpiringSoon(key.expiresAt)" class="text-orange-600">
<i class="fas fa-clock mr-1"></i>
<div
v-else-if="isApiKeyExpiringSoon(key.expiresAt)"
class="text-orange-600"
>
<i class="fas fa-clock mr-1" />
{{ formatExpireDate(key.expiresAt) }}
</div>
<div v-else class="text-gray-600">
<div
v-else
class="text-gray-600"
>
{{ formatExpireDate(key.expiresAt) }}
</div>
</div>
<div v-else class="text-gray-400">
<i class="fas fa-infinity mr-1"></i>
<div
v-else
class="text-gray-400"
>
<i class="fas fa-infinity mr-1" />
永不过期
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div class="flex gap-2">
<button
@click="copyApiStatsLink(key)"
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-3 py-1 rounded-lg transition-colors"
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-3 py-1 rounded-lg transition-colors"
title="复制统计页面链接"
@click="copyApiStatsLink(key)"
>
<i class="fas fa-chart-bar mr-1"></i>统计
<i class="fas fa-chart-bar mr-1" />统计
</button>
<button
@click="openEditApiKeyModal(key)"
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
@click="openEditApiKeyModal(key)"
>
<i class="fas fa-edit mr-1"></i>编辑
<i class="fas fa-edit mr-1" />编辑
</button>
<button
v-if="key.expiresAt && (isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))"
@click="openRenewApiKeyModal(key)"
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
@click="openRenewApiKeyModal(key)"
>
<i class="fas fa-clock mr-1"></i>续期
<i class="fas fa-clock mr-1" />续期
</button>
<button
@click="deleteApiKey(key.id)"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
@click="deleteApiKey(key.id)"
>
<i class="fas fa-trash mr-1"></i>删除
<i class="fas fa-trash mr-1" />删除
</button>
</div>
</td>
@@ -267,20 +400,31 @@
<!-- 模型统计展开区域 -->
<tr v-if="key && key.id && expandedApiKeys[key.id]">
<td colspan="7" class="px-6 py-4 bg-gray-50">
<div v-if="!apiKeyModelStats[key.id]" class="text-center py-4">
<div class="loading-spinner mx-auto"></div>
<p class="text-sm text-gray-500 mt-2">加载模型统计...</p>
<td
colspan="7"
class="px-6 py-4 bg-gray-50"
>
<div
v-if="!apiKeyModelStats[key.id]"
class="text-center py-4"
>
<div class="loading-spinner mx-auto" />
<p class="text-sm text-gray-500 mt-2">
加载模型统计...
</p>
</div>
<div class="space-y-4">
<!-- 通用的标题和时间筛选器无论是否有数据都显示 -->
<div class="flex items-center justify-between mb-4">
<h5 class="text-sm font-semibold text-gray-700 flex items-center">
<i class="fas fa-chart-pie text-indigo-500 mr-2"></i>
<i class="fas fa-chart-pie text-indigo-500 mr-2" />
模型使用分布
</h5>
<div class="flex items-center gap-2">
<span v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
<span
v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0"
class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full"
>
{{ apiKeyModelStats[key.id].length }} 个模型
</span>
@@ -291,13 +435,13 @@
<button
v-for="option in getApiKeyDateFilter(key.id).presetOptions"
:key="option.value"
@click="setApiKeyDateFilterPreset(option.value, key.id)"
:class="[
'px-2 py-1 rounded text-xs font-medium transition-colors',
getApiKeyDateFilter(key.id).preset === option.value && getApiKeyDateFilter(key.id).type === 'preset'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="setApiKeyDateFilterPreset(option.value, key.id)"
>
{{ option.label }}
</button>
@@ -306,7 +450,6 @@
<!-- Element Plus 日期范围选择器 -->
<el-date-picker
:model-value="getApiKeyDateFilter(key.id).customRange"
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
@@ -317,33 +460,47 @@
:default-time="defaultTime"
size="small"
style="width: 280px;"
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
class="api-key-date-picker"
:clearable="true"
:unlink-panels="false"
></el-date-picker>
/>
</div>
</div>
</div>
<!-- 数据展示区域 -->
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length === 0" class="text-center py-8">
<div
v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length === 0"
class="text-center py-8"
>
<div class="flex items-center justify-center gap-2 mb-3">
<i class="fas fa-chart-line text-gray-400 text-lg"></i>
<p class="text-sm text-gray-500">暂无模型使用数据</p>
<i class="fas fa-chart-line text-gray-400 text-lg" />
<p class="text-sm text-gray-500">
暂无模型使用数据
</p>
<button
@click="resetApiKeyDateFilter(key.id)"
class="text-blue-500 hover:text-blue-700 text-sm ml-2 flex items-center gap-1 transition-colors"
title="重置筛选条件并刷新"
@click="resetApiKeyDateFilter(key.id)"
>
<i class="fas fa-sync-alt text-xs"></i>
<i class="fas fa-sync-alt text-xs" />
<span class="text-xs">刷新</span>
</button>
</div>
<p class="text-xs text-gray-400">尝试调整时间范围或点击刷新重新加载数据</p>
<p class="text-xs text-gray-400">
尝试调整时间范围或点击刷新重新加载数据
</p>
</div>
<div v-else-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="stat in apiKeyModelStats[key.id]" :key="stat.model"
class="bg-gradient-to-br from-white to-gray-50 rounded-xl p-4 border border-gray-200 hover:border-indigo-300 hover:shadow-lg transition-all duration-200">
<div
v-else-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<div
v-for="stat in apiKeyModelStats[key.id]"
:key="stat.model"
class="bg-gradient-to-br from-white to-gray-50 rounded-xl p-4 border border-gray-200 hover:border-indigo-300 hover:shadow-lg transition-all duration-200"
>
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<span class="text-sm font-semibold text-gray-800 block mb-1">{{ stat.model }}</span>
@@ -354,14 +511,14 @@
<div class="space-y-2 mb-3">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 flex items-center">
<i class="fas fa-coins text-yellow-500 mr-1 text-xs"></i>
<i class="fas fa-coins text-yellow-500 mr-1 text-xs" />
总Token:
</span>
<span class="font-semibold text-gray-900">{{ formatNumber(stat.allTokens) }}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 flex items-center">
<i class="fas fa-dollar-sign text-green-500 mr-1 text-xs"></i>
<i class="fas fa-dollar-sign text-green-500 mr-1 text-xs" />
费用:
</span>
<span class="font-semibold text-green-600">{{ calculateModelCost(stat) }}</span>
@@ -369,28 +526,34 @@
<div class="pt-2 mt-2 border-t border-gray-100">
<div class="flex justify-between items-center text-xs text-gray-500">
<span class="flex items-center">
<i class="fas fa-arrow-down text-green-500 mr-1"></i>
<i class="fas fa-arrow-down text-green-500 mr-1" />
输入:
</span>
<span class="font-medium">{{ formatNumber(stat.inputTokens) }}</span>
</div>
<div class="flex justify-between items-center text-xs text-gray-500">
<span class="flex items-center">
<i class="fas fa-arrow-up text-blue-500 mr-1"></i>
<i class="fas fa-arrow-up text-blue-500 mr-1" />
输出:
</span>
<span class="font-medium">{{ formatNumber(stat.outputTokens) }}</span>
</div>
<div v-if="stat.cacheCreateTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
<div
v-if="stat.cacheCreateTokens > 0"
class="flex justify-between items-center text-xs text-purple-600"
>
<span class="flex items-center">
<i class="fas fa-save mr-1"></i>
<i class="fas fa-save mr-1" />
缓存创建:
</span>
<span class="font-medium">{{ formatNumber(stat.cacheCreateTokens) }}</span>
</div>
<div v-if="stat.cacheReadTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
<div
v-if="stat.cacheReadTokens > 0"
class="flex justify-between items-center text-xs text-purple-600"
>
<span class="flex items-center">
<i class="fas fa-download mr-1"></i>
<i class="fas fa-download mr-1" />
缓存读取:
</span>
<span class="font-medium">{{ formatNumber(stat.cacheReadTokens) }}</span>
@@ -400,9 +563,10 @@
<!-- 进度条 -->
<div class="w-full bg-gray-200 rounded-full h-2 mt-3">
<div class="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all duration-500"
:style="{ width: calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) + '%' }">
</div>
<div
class="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all duration-500"
:style="{ width: calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) + '%' }"
/>
</div>
<div class="text-right mt-1">
<span class="text-xs font-medium text-indigo-600">
@@ -413,10 +577,13 @@
</div>
<!-- 总计统计,仅在有数据时显示 -->
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="mt-4 p-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border border-indigo-100">
<div
v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0"
class="mt-4 p-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border border-indigo-100"
>
<div class="flex items-center justify-between text-sm">
<span class="font-semibold text-gray-700 flex items-center">
<i class="fas fa-calculator text-indigo-500 mr-2"></i>
<i class="fas fa-calculator text-indigo-500 mr-2" />
总计统计
</span>
<div class="flex gap-4 text-xs">
@@ -448,7 +615,7 @@
<EditApiKeyModal
v-if="showEditApiKeyModal"
:apiKey="editingApiKey"
:api-key="editingApiKey"
:accounts="accounts"
@close="showEditApiKeyModal = false"
@success="handleEditSuccess"
@@ -456,14 +623,14 @@
<RenewApiKeyModal
v-if="showRenewApiKeyModal"
:apiKey="renewingApiKey"
:api-key="renewingApiKey"
@close="showRenewApiKeyModal = false"
@success="handleRenewSuccess"
/>
<NewApiKeyModal
v-if="showNewApiKeyModal"
:apiKey="newApiKeyData"
:api-key="newApiKeyData"
@close="showNewApiKeyModal = false"
/>
</div>

View File

@@ -10,8 +10,11 @@
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
/>
<div class="flex items-center gap-3">
<router-link to="/dashboard" class="admin-button rounded-xl px-4 py-2 text-white transition-all duration-300 flex items-center gap-2">
<i class="fas fa-cog text-sm"></i>
<router-link
to="/dashboard"
class="admin-button rounded-xl px-4 py-2 text-white transition-all duration-300 flex items-center gap-2"
>
<i class="fas fa-cog text-sm" />
<span class="text-sm font-medium">管理后台</span>
</router-link>
</div>
@@ -23,23 +26,23 @@
<div class="flex justify-center">
<div class="inline-flex bg-white/10 backdrop-blur-xl rounded-full p-1 shadow-lg border border-white/20">
<button
@click="currentTab = 'stats'"
:class="[
'tab-pill-button',
currentTab === 'stats' ? 'active' : ''
]"
@click="currentTab = 'stats'"
>
<i class="fas fa-chart-line mr-2"></i>
<i class="fas fa-chart-line mr-2" />
<span>统计查询</span>
</button>
<button
@click="currentTab = 'tutorial'"
:class="[
'tab-pill-button',
currentTab === 'tutorial' ? 'active' : ''
]"
@click="currentTab = 'tutorial'"
>
<i class="fas fa-graduation-cap mr-2"></i>
<i class="fas fa-graduation-cap mr-2" />
<span>使用教程</span>
</button>
</div>
@@ -47,45 +50,54 @@
</div>
<!-- 统计内容 -->
<div v-if="currentTab === 'stats'" class="tab-content">
<div
v-if="currentTab === 'stats'"
class="tab-content"
>
<!-- API Key 输入区域 -->
<ApiKeyInput />
<!-- 错误提示 -->
<div v-if="error" class="mb-8">
<div
v-if="error"
class="mb-8"
>
<div class="bg-red-500/20 border border-red-500/30 rounded-xl p-4 text-red-800 backdrop-blur-sm">
<i class="fas fa-exclamation-triangle mr-2"></i>
<i class="fas fa-exclamation-triangle mr-2" />
{{ error }}
</div>
</div>
<!-- 统计数据展示区域 -->
<div v-if="statsData" class="fade-in">
<div
v-if="statsData"
class="fade-in"
>
<div class="glass-strong rounded-3xl p-6 shadow-xl">
<!-- 时间范围选择器 -->
<div class="mb-6 pb-6 border-b border-gray-200">
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<i class="fas fa-clock text-blue-500 text-lg"></i>
<i class="fas fa-clock text-blue-500 text-lg" />
<span class="text-lg font-medium text-gray-700">统计时间范围</span>
</div>
<div class="flex gap-2">
<button
@click="switchPeriod('daily')"
:class="['period-btn', { 'active': statsPeriod === 'daily' }]"
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
:disabled="loading || modelStatsLoading"
@click="switchPeriod('daily')"
>
<i class="fas fa-calendar-day"></i>
<i class="fas fa-calendar-day" />
今日
</button>
<button
@click="switchPeriod('monthly')"
:class="['period-btn', { 'active': statsPeriod === 'monthly' }]"
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
:disabled="loading || modelStatsLoading"
@click="switchPeriod('monthly')"
>
<i class="fas fa-calendar-alt"></i>
<i class="fas fa-calendar-alt" />
本月
</button>
</div>
@@ -108,7 +120,10 @@
</div>
<!-- 教程内容 -->
<div v-if="currentTab === 'tutorial'" class="tab-content">
<div
v-if="currentTab === 'tutorial'"
class="tab-content"
>
<div class="glass-strong rounded-3xl shadow-xl">
<TutorialView />
</div>

View File

@@ -5,12 +5,18 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">总API Keys</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalApiKeys }}</p>
<p class="text-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeApiKeys || 0 }}</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
总API Keys
</p>
<p class="text-3xl font-bold text-gray-900">
{{ dashboardData.totalApiKeys }}
</p>
<p class="text-xs text-gray-500 mt-1">
活跃: {{ dashboardData.activeApiKeys || 0 }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
<i class="fas fa-key"></i>
<i class="fas fa-key" />
</div>
</div>
</div>
@@ -18,17 +24,24 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">服务账户</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
服务账户
</p>
<p class="text-3xl font-bold text-gray-900">
{{ dashboardData.totalAccounts }}
</p>
<p class="text-xs text-gray-500 mt-1">
活跃: {{ dashboardData.activeAccounts || 0 }}
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
<span
v-if="dashboardData.rateLimitedAccounts > 0"
class="text-yellow-600"
>
| 限流: {{ dashboardData.rateLimitedAccounts }}
</span>
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-green-500 to-green-600">
<i class="fas fa-user-circle"></i>
<i class="fas fa-user-circle" />
</div>
</div>
</div>
@@ -36,12 +49,18 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">今日请求</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.todayRequests }}</p>
<p class="text-xs text-gray-500 mt-1">总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
今日请求
</p>
<p class="text-3xl font-bold text-gray-900">
{{ dashboardData.todayRequests }}
</p>
<p class="text-xs text-gray-500 mt-1">
总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600">
<i class="fas fa-chart-line"></i>
<i class="fas fa-chart-line" />
</div>
</div>
</div>
@@ -49,12 +68,18 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">系统状态</p>
<p class="text-3xl font-bold text-green-600">{{ dashboardData.systemStatus }}</p>
<p class="text-xs text-gray-500 mt-1">运行时间: {{ formattedUptime }}</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
系统状态
</p>
<p class="text-3xl font-bold text-green-600">
{{ dashboardData.systemStatus }}
</p>
<p class="text-xs text-gray-500 mt-1">
运行时间: {{ formattedUptime }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500">
<i class="fas fa-heartbeat"></i>
<i class="fas fa-heartbeat" />
</div>
</div>
</div>
@@ -65,22 +90,32 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex-1 mr-8">
<p class="text-sm font-semibold text-gray-600 mb-1">今日Token</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
今日Token
</p>
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
<p class="text-3xl font-bold text-blue-600">{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}</p>
<p class="text-3xl font-bold text-blue-600">
{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}
</p>
<span class="text-sm text-green-600 font-medium">/ {{ costsData.todayCosts.formatted.totalCost }}</span>
</div>
<div class="text-xs text-gray-500">
<div class="flex justify-between items-center flex-wrap gap-x-4">
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.todayInputTokens || 0) }}</span></span>
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.todayOutputTokens || 0) }}</span></span>
<span v-if="(dashboardData.todayCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheCreateTokens || 0) }}</span></span>
<span v-if="(dashboardData.todayCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheReadTokens || 0) }}</span></span>
<span
v-if="(dashboardData.todayCacheCreateTokens || 0) > 0"
class="text-purple-600"
>缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheCreateTokens || 0) }}</span></span>
<span
v-if="(dashboardData.todayCacheReadTokens || 0) > 0"
class="text-purple-600"
>缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheReadTokens || 0) }}</span></span>
</div>
</div>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-indigo-500 to-indigo-600">
<i class="fas fa-coins"></i>
<i class="fas fa-coins" />
</div>
</div>
</div>
@@ -88,22 +123,32 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex-1 mr-8">
<p class="text-sm font-semibold text-gray-600 mb-1">总Token消耗</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
总Token消耗
</p>
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
<p class="text-3xl font-bold text-emerald-600">{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}</p>
<p class="text-3xl font-bold text-emerald-600">
{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}
</p>
<span class="text-sm text-green-600 font-medium">/ {{ costsData.totalCosts.formatted.totalCost }}</span>
</div>
<div class="text-xs text-gray-500">
<div class="flex justify-between items-center flex-wrap gap-x-4">
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.totalInputTokens || 0) }}</span></span>
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.totalOutputTokens || 0) }}</span></span>
<span v-if="(dashboardData.totalCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheCreateTokens || 0) }}</span></span>
<span v-if="(dashboardData.totalCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheReadTokens || 0) }}</span></span>
<span
v-if="(dashboardData.totalCacheCreateTokens || 0) > 0"
class="text-purple-600"
>缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheCreateTokens || 0) }}</span></span>
<span
v-if="(dashboardData.totalCacheReadTokens || 0) > 0"
class="text-purple-600"
>缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheReadTokens || 0) }}</span></span>
</div>
</div>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-emerald-600">
<i class="fas fa-database"></i>
<i class="fas fa-database" />
</div>
</div>
</div>
@@ -111,12 +156,25 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">平均RPM</p>
<p class="text-3xl font-bold text-orange-600">{{ dashboardData.systemRPM || 0 }}</p>
<p class="text-xs text-gray-500 mt-1">分钟请求数</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
实时RPM
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
</p>
<p class="text-3xl font-bold text-orange-600">
{{ dashboardData.realtimeRPM || 0 }}
</p>
<p class="text-xs text-gray-500 mt-1">
每分钟请求数
<span
v-if="dashboardData.isHistoricalMetrics"
class="text-yellow-600"
>
<i class="fas fa-exclamation-circle" /> 历史数据
</span>
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-orange-500 to-orange-600">
<i class="fas fa-tachometer-alt"></i>
<i class="fas fa-tachometer-alt" />
</div>
</div>
</div>
@@ -124,12 +182,25 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">平均TPM</p>
<p class="text-3xl font-bold text-rose-600">{{ dashboardData.systemTPM || 0 }}</p>
<p class="text-xs text-gray-500 mt-1">分钟Token数</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
实时TPM
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
</p>
<p class="text-3xl font-bold text-rose-600">
{{ formatNumber(dashboardData.realtimeTPM || 0) }}
</p>
<p class="text-xs text-gray-500 mt-1">
每分钟Token数
<span
v-if="dashboardData.isHistoricalMetrics"
class="text-yellow-600"
>
<i class="fas fa-exclamation-circle" /> 历史数据
</span>
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-rose-500 to-rose-600">
<i class="fas fa-rocket"></i>
<i class="fas fa-rocket" />
</div>
</div>
</div>
@@ -137,21 +208,23 @@
<!-- 模型消费统计 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900">模型使用分布与Token使用趋势</h3>
<div class="flex gap-2 items-center">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
<h3 class="text-xl font-bold text-gray-900">
模型使用分布与Token使用趋势
</h3>
<div class="flex flex-wrap gap-2 items-center">
<!-- 快捷日期选择 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
v-for="option in dateFilter.presetOptions"
:key="option.value"
@click="setDateFilterPreset(option.value)"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
dateFilter.preset === option.value && dateFilter.type === 'preset'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="setDateFilterPreset(option.value)"
>
{{ option.label }}
</button>
@@ -160,89 +233,163 @@
<!-- 粒度切换按钮 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
@click="setTrendGranularity('day')"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
trendGranularity === 'day'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="setTrendGranularity('day')"
>
<i class="fas fa-calendar-day mr-1"></i>按天
<i class="fas fa-calendar-day mr-1" />按天
</button>
<button
@click="setTrendGranularity('hour')"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
trendGranularity === 'hour'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="setTrendGranularity('hour')"
>
<i class="fas fa-clock mr-1"></i>按小时
<i class="fas fa-clock mr-1" />按小时
</button>
</div>
<!-- Element Plus 日期范围选择器 -->
<div class="flex items-center gap-2">
<el-date-picker
:default-time="defaultTime"
v-model="dateFilter.customRange"
:default-time="defaultTime"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="onCustomDateRangeChange"
:disabled-date="disabledDate"
size="default"
style="width: 400px;"
class="custom-date-picker"
></el-date-picker>
<span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600">
<i class="fas fa-info-circle"></i> 最多24小时
@change="onCustomDateRangeChange"
/>
<span
v-if="trendGranularity === 'hour'"
class="text-xs text-orange-600"
>
<i class="fas fa-info-circle" /> 最多24小时
</span>
</div>
<button @click="refreshChartsData()" class="btn btn-primary px-4 py-2 flex items-center gap-2">
<i class="fas fa-sync-alt"></i>刷新
</button>
<!-- 刷新控制 -->
<div class="flex items-center gap-2">
<!-- 自动刷新控制 -->
<div class="flex items-center bg-gray-100 rounded-lg px-3 py-1">
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="autoRefreshEnabled"
type="checkbox"
class="sr-only peer"
>
<!-- 更小的开关 -->
<div class="relative w-9 h-5 bg-gray-300 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 rounded-full peer peer-checked:bg-blue-500 transition-all duration-200 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:w-4 after:h-4 after:rounded-full after:shadow-sm after:transition-transform after:duration-200 peer-checked:after:translate-x-4" />
<span class="ml-2.5 text-sm font-medium text-gray-600 select-none flex items-center gap-1">
<i class="fas fa-redo-alt text-xs text-gray-500" />
<span>自动刷新</span>
<span
v-if="autoRefreshEnabled"
class="ml-1 text-xs text-blue-600 font-mono transition-opacity"
:class="refreshCountdown > 0 ? 'opacity-100' : 'opacity-0'"
>
{{ refreshCountdown }}s
</span>
</span>
</label>
</div>
<!-- 刷新按钮 -->
<button
:disabled="isRefreshing"
class="px-3 py-1 rounded-md text-sm font-medium transition-colors bg-white text-blue-600 shadow-sm hover:bg-gray-50 border border-gray-300 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
title="立即刷新数据"
@click="refreshAllData()"
>
<i :class="['fas fa-sync-alt text-xs', { 'animate-spin': isRefreshing }]" />
<span>{{ isRefreshing ? '刷新中' : '刷新' }}</span>
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 饼图 -->
<div class="card p-6">
<h4 class="text-lg font-semibold text-gray-800 mb-4">Token使用分布</h4>
<div class="relative" style="height: 300px;">
<canvas ref="modelUsageChart"></canvas>
<h4 class="text-lg font-semibold text-gray-800 mb-4">
Token使用分布
</h4>
<div
class="relative"
style="height: 300px;"
>
<canvas ref="modelUsageChart" />
</div>
</div>
<!-- 详细数据表格 -->
<div class="card p-6">
<h4 class="text-lg font-semibold text-gray-800 mb-4">详细统计数据</h4>
<div v-if="dashboardModelStats.length === 0" class="text-center py-8">
<p class="text-gray-500">暂无模型使用数据</p>
<h4 class="text-lg font-semibold text-gray-800 mb-4">
详细统计数据
</h4>
<div
v-if="dashboardModelStats.length === 0"
class="text-center py-8"
>
<p class="text-gray-500">
暂无模型使用数据
</p>
</div>
<div v-else class="overflow-auto max-h-[300px]">
<div
v-else
class="overflow-auto max-h-[300px]"
>
<table class="min-w-full">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-700">模型</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">请求数</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">总Token</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">费用</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">占比</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-700">
模型
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">
请求数
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">
总Token
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">
费用
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">
占比
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="stat in dashboardModelStats" :key="stat.model" class="hover:bg-gray-50">
<td class="px-4 py-2 text-sm text-gray-900">{{ stat.model }}</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.requests) }}</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.allTokens) }}</td>
<td class="px-4 py-2 text-sm text-green-600 text-right font-medium">{{ stat.formatted ? stat.formatted.total : '$0.000000' }}</td>
<tr
v-for="stat in dashboardModelStats"
:key="stat.model"
class="hover:bg-gray-50"
>
<td class="px-4 py-2 text-sm text-gray-900">
{{ stat.model }}
</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">
{{ formatNumber(stat.requests) }}
</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">
{{ formatNumber(stat.allTokens) }}
</td>
<td class="px-4 py-2 text-sm text-green-600 text-right font-medium">
{{ stat.formatted ? stat.formatted.total : '$0.000000' }}
</td>
<td class="px-4 py-2 text-sm font-medium text-right">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ calculatePercentage(stat.allTokens, dashboardModelStats) }}%
@@ -260,7 +407,7 @@
<div class="mb-8">
<div class="card p-6">
<div style="height: 300px;">
<canvas ref="usageTrendChart"></canvas>
<canvas ref="usageTrendChart" />
</div>
</div>
</div>
@@ -269,30 +416,32 @@
<div class="mb-8">
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">API Keys 使用趋势</h3>
<h3 class="text-lg font-semibold text-gray-900">
API Keys 使用趋势
</h3>
<!-- 维度切换按钮 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
@click="apiKeysTrendMetric = 'requests'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'requests'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="apiKeysTrendMetric = 'requests'; updateApiKeysUsageTrendChart()"
>
<i class="fas fa-exchange-alt mr-1"></i>请求次数
<i class="fas fa-exchange-alt mr-1" />请求次数
</button>
<button
@click="apiKeysTrendMetric = 'tokens'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'tokens'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="apiKeysTrendMetric = 'tokens'; updateApiKeysUsageTrendChart()"
>
<i class="fas fa-coins mr-1"></i>Token 数量
<i class="fas fa-coins mr-1" />Token 数量
</button>
</div>
</div>
@@ -305,7 +454,7 @@
</span>
</div>
<div style="height: 350px;">
<canvas ref="apiKeysUsageTrendChart"></canvas>
<canvas ref="apiKeysUsageTrendChart" />
</div>
</div>
</div>
@@ -313,7 +462,7 @@
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useDashboardStore } from '@/stores/dashboard'
import Chart from 'chart.js/auto'
@@ -334,8 +483,6 @@ const {
const {
loadDashboardData,
loadUsageTrend,
loadModelStats,
loadApiKeysTrend,
setDateFilterPreset,
onCustomDateRangeChange,
@@ -352,6 +499,20 @@ let modelUsageChartInstance = null
let usageTrendChartInstance = null
let apiKeysUsageTrendChartInstance = null
// 自动刷新相关
const autoRefreshEnabled = ref(false)
const autoRefreshInterval = ref(30) // 秒
const autoRefreshTimer = ref(null)
const refreshCountdown = ref(0)
const countdownTimer = ref(null)
const isRefreshing = ref(false)
// 计算倒计时显示
const refreshCountdownDisplay = computed(() => {
if (!autoRefreshEnabled.value || refreshCountdown.value <= 0) return ''
return `${refreshCountdown.value}秒后刷新`
})
// 格式化数字
function formatNumber(num) {
if (num >= 1000000) {
@@ -543,6 +704,22 @@ function createUsageTrendChart() {
tooltip: {
mode: 'index',
intersect: false,
itemSort: function(a, b) {
// 按值倒序排列,费用和请求数特殊处理
const aLabel = a.dataset.label || ''
const bLabel = b.dataset.label || ''
// 费用和请求数使用不同的轴,单独处理
if (aLabel === '费用 (USD)' || bLabel === '费用 (USD)') {
return aLabel === '费用 (USD)' ? -1 : 1
}
if (aLabel === '请求数' || bLabel === '请求数') {
return aLabel === '请求数' ? 1 : -1
}
// 其他按token值倒序
return b.parsed.y - a.parsed.y
},
callbacks: {
label: function(context) {
const label = context.dataset.label || ''
@@ -558,7 +735,14 @@ function createUsageTrendChart() {
} else if (label === '请求数') {
return label + ': ' + value.toLocaleString() + ' 次'
} else {
return label + ': ' + value.toLocaleString() + ' tokens'
// 格式化token数显示
if (value >= 1000000) {
return label + ': ' + (value / 1000000).toFixed(2) + 'M tokens'
} else if (value >= 1000) {
return label + ': ' + (value / 1000).toFixed(2) + 'K tokens'
} else {
return label + ': ' + value.toLocaleString() + ' tokens'
}
}
}
}
@@ -706,12 +890,52 @@ function createApiKeysUsageTrendChart() {
tooltip: {
mode: 'index',
intersect: false,
itemSort: function(a, b) {
// 按值倒序排列
return b.parsed.y - a.parsed.y
},
callbacks: {
label: function(context) {
const label = context.dataset.label || ''
const value = context.parsed.y
const unit = apiKeysTrendMetric.value === 'tokens' ? ' tokens' : ' 次'
return label + ': ' + value.toLocaleString() + unit
const dataIndex = context.dataIndex
const dataPoint = apiKeysTrendData.value.data[dataIndex]
// 获取所有数据集在这个时间点的值,用于排名
const allValues = context.chart.data.datasets.map((dataset, idx) => ({
value: dataset.data[dataIndex] || 0,
index: idx
})).sort((a, b) => b.value - a.value)
// 找出当前数据集的排名
const rank = allValues.findIndex(item => item.index === context.datasetIndex) + 1
// 准备排名标识
let rankIcon = ''
if (rank === 1) rankIcon = '🥇 '
else if (rank === 2) rankIcon = '🥈 '
else if (rank === 3) rankIcon = '🥉 '
if (apiKeysTrendMetric.value === 'tokens') {
// 格式化token显示
let formattedValue = ''
if (value >= 1000000) {
formattedValue = (value / 1000000).toFixed(2) + 'M'
} else if (value >= 1000) {
formattedValue = (value / 1000).toFixed(2) + 'K'
} else {
formattedValue = value.toLocaleString()
}
// 获取对应API Key的费用信息
const apiKeyId = apiKeysTrendData.value.topApiKeys[context.datasetIndex]
const apiKeyData = dataPoint?.apiKeys?.[apiKeyId]
const cost = apiKeyData?.formattedCost || '$0.00'
return `${rankIcon}${label}: ${formattedValue} tokens (${cost})`
} else {
return `${rankIcon}${label}: ${value.toLocaleString()}`
}
}
}
}
@@ -762,13 +986,90 @@ watch(apiKeysTrendData, () => {
nextTick(() => createApiKeysUsageTrendChart())
})
// 刷新所有数据
async function refreshAllData() {
if (isRefreshing.value) return
isRefreshing.value = true
try {
await Promise.all([
loadDashboardData(),
refreshChartsData()
])
} finally {
isRefreshing.value = false
}
}
// 启动自动刷新
function startAutoRefresh() {
if (!autoRefreshEnabled.value) return
// 重置倒计时
refreshCountdown.value = autoRefreshInterval.value
// 清除现有定时器
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
}
if (autoRefreshTimer.value) {
clearTimeout(autoRefreshTimer.value)
}
// 启动倒计时
countdownTimer.value = setInterval(() => {
refreshCountdown.value--
if (refreshCountdown.value <= 0) {
clearInterval(countdownTimer.value)
}
}, 1000)
// 设置刷新定时器
autoRefreshTimer.value = setTimeout(async () => {
await refreshAllData()
// 递归调用以继续自动刷新
if (autoRefreshEnabled.value) {
startAutoRefresh()
}
}, autoRefreshInterval.value * 1000)
}
// 停止自动刷新
function stopAutoRefresh() {
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
countdownTimer.value = null
}
if (autoRefreshTimer.value) {
clearTimeout(autoRefreshTimer.value)
autoRefreshTimer.value = null
}
refreshCountdown.value = 0
}
// 切换自动刷新
function toggleAutoRefresh() {
autoRefreshEnabled.value = !autoRefreshEnabled.value
if (autoRefreshEnabled.value) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
// 监听自动刷新状态变化
watch(autoRefreshEnabled, (newVal) => {
if (newVal) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
})
// 初始化
onMounted(async () => {
// 加载所有数据
await Promise.all([
loadDashboardData(),
refreshChartsData() // 使用refreshChartsData来确保根据当前筛选条件加载数据
])
await refreshAllData()
// 创建图表
await nextTick()
@@ -776,6 +1077,21 @@ onMounted(async () => {
createUsageTrendChart()
createApiKeysUsageTrendChart()
})
// 清理
onUnmounted(() => {
stopAutoRefresh()
// 销毁图表实例
if (modelUsageChartInstance) {
modelUsageChartInstance.destroy()
}
if (usageTrendChartInstance) {
usageTrendChartInstance.destroy()
}
if (apiKeysUsageTrendChartInstance) {
apiKeysUsageTrendChartInstance.destroy()
}
})
</script>
<style scoped>
@@ -794,4 +1110,18 @@ onMounted(async () => {
.custom-date-picker :deep(.el-range-input) {
font-size: 13px;
}
/* 旋转动画 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -5,23 +5,41 @@
<!-- 使用自定义布局来保持登录页面的居中大logo样式 -->
<div class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden">
<template v-if="!oemLoading">
<img v-if="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
:src="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
alt="Logo"
class="w-12 h-12 object-contain"
@error="(e) => e.target.style.display = 'none'">
<i v-else class="fas fa-cloud text-3xl text-gray-700"></i>
<img
v-if="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
:src="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
alt="Logo"
class="w-12 h-12 object-contain"
@error="(e) => e.target.style.display = 'none'"
>
<i
v-else
class="fas fa-cloud text-3xl text-gray-700"
/>
</template>
<div v-else class="w-12 h-12 bg-gray-300/50 rounded animate-pulse"></div>
<div
v-else
class="w-12 h-12 bg-gray-300/50 rounded animate-pulse"
/>
</div>
<template v-if="!oemLoading && authStore.oemSettings.siteName">
<h1 class="text-3xl font-bold text-white mb-2 header-title">{{ authStore.oemSettings.siteName }}</h1>
<h1 class="text-3xl font-bold text-white mb-2 header-title">
{{ authStore.oemSettings.siteName }}
</h1>
</template>
<div v-else-if="oemLoading" class="h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"></div>
<p class="text-gray-600 text-lg">管理后台</p>
<div
v-else-if="oemLoading"
class="h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"
/>
<p class="text-gray-600 text-lg">
管理后台
</p>
</div>
<form @submit.prevent="handleLogin" class="space-y-6">
<form
class="space-y-6"
@submit.prevent="handleLogin"
>
<div>
<label class="block text-sm font-semibold text-gray-900 mb-3">用户名</label>
<input
@@ -49,14 +67,23 @@
:disabled="authStore.loginLoading"
class="btn btn-primary w-full py-4 px-6 text-lg font-semibold"
>
<i v-if="!authStore.loginLoading" class="fas fa-sign-in-alt mr-2"></i>
<div v-if="authStore.loginLoading" class="loading-spinner mr-2"></div>
<i
v-if="!authStore.loginLoading"
class="fas fa-sign-in-alt mr-2"
/>
<div
v-if="authStore.loginLoading"
class="loading-spinner mr-2"
/>
{{ authStore.loginLoading ? '登录中...' : '登录' }}
</button>
</form>
<div v-if="authStore.loginError" class="mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm">
<i class="fas fa-exclamation-triangle mr-2"></i>{{ authStore.loginError }}
<div
v-if="authStore.loginError"
class="mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm"
>
<i class="fas fa-exclamation-triangle mr-2" />{{ authStore.loginError }}
</div>
</div>
</div>

View File

@@ -3,17 +3,29 @@
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">其他设置</h3>
<p class="text-gray-600">自定义网站名称和图标</p>
<h3 class="text-xl font-bold text-gray-900 mb-2">
其他设置
</h3>
<p class="text-gray-600">
自定义网站名称和图标
</p>
</div>
</div>
<div v-if="loading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载设置...</p>
<div
v-if="loading"
class="text-center py-12"
>
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500">
正在加载设置...
</p>
</div>
<div v-else class="table-container">
<div
v-else
class="table-container"
>
<table class="min-w-full">
<tbody class="divide-y divide-gray-200/50">
<!-- 网站名称 -->
@@ -21,11 +33,15 @@
<td class="px-6 py-4 whitespace-nowrap w-48">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-font text-white text-xs"></i>
<i class="fas fa-font text-white text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-gray-900">网站名称</div>
<div class="text-xs text-gray-500">品牌标识</div>
<div class="text-sm font-semibold text-gray-900">
网站名称
</div>
<div class="text-xs text-gray-500">
品牌标识
</div>
</div>
</div>
</td>
@@ -37,7 +53,9 @@
placeholder="Claude Relay Service"
maxlength="100"
>
<p class="text-xs text-gray-500 mt-1">将显示在浏览器标题和页面头部</p>
<p class="text-xs text-gray-500 mt-1">
将显示在浏览器标题和页面头部
</p>
</td>
</tr>
@@ -46,18 +64,25 @@
<td class="px-6 py-4 whitespace-nowrap w-48">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-image text-white text-xs"></i>
<i class="fas fa-image text-white text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-gray-900">网站图标</div>
<div class="text-xs text-gray-500">Favicon</div>
<div class="text-sm font-semibold text-gray-900">
网站图标
</div>
<div class="text-xs text-gray-500">
Favicon
</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="space-y-3">
<!-- 图标预览 -->
<div v-if="oemSettings.siteIconData || oemSettings.siteIcon" class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<div
v-if="oemSettings.siteIconData || oemSettings.siteIcon"
class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg"
>
<img
:src="oemSettings.siteIconData || oemSettings.siteIcon"
alt="图标预览"
@@ -66,27 +91,27 @@
>
<span class="text-sm text-gray-600">当前图标</span>
<button
@click="removeIcon"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
@click="removeIcon"
>
<i class="fas fa-trash mr-1"></i>删除
<i class="fas fa-trash mr-1" />删除
</button>
</div>
<!-- 文件上传 -->
<div>
<input
type="file"
ref="iconFileInput"
@change="handleIconUpload"
ref="iconFileInput"
type="file"
accept=".ico,.png,.jpg,.jpeg,.svg"
class="hidden"
@change="handleIconUpload"
>
<button
@click="$refs.iconFileInput.click()"
class="btn btn-success px-4 py-2"
@click="$refs.iconFileInput.click()"
>
<i class="fas fa-upload mr-2"></i>
<i class="fas fa-upload mr-2" />
上传图标
</button>
<span class="text-xs text-gray-500 ml-3">支持 .ico, .png, .jpg, .svg 格式最大 350KB</span>
@@ -97,32 +122,44 @@
<!-- 操作按钮 -->
<tr>
<td class="px-6 py-6" colspan="2">
<td
class="px-6 py-6"
colspan="2"
>
<div class="flex items-center justify-between">
<div class="flex gap-3">
<button
@click="saveOemSettings"
:disabled="saving"
class="btn btn-primary px-6 py-3"
:class="{ 'opacity-50 cursor-not-allowed': saving }"
@click="saveOemSettings"
>
<div v-if="saving" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2"></i>
<div
v-if="saving"
class="loading-spinner mr-2"
/>
<i
v-else
class="fas fa-save mr-2"
/>
{{ saving ? '保存中...' : '保存设置' }}
</button>
<button
@click="resetOemSettings"
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
:disabled="saving"
@click="resetOemSettings"
>
<i class="fas fa-undo mr-2"></i>
<i class="fas fa-undo mr-2" />
重置为默认
</button>
</div>
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>
<div
v-if="oemSettings.updatedAt"
class="text-sm text-gray-500"
>
<i class="fas fa-clock mr-1" />
最后更新{{ formatDateTime(oemSettings.updatedAt) }}
</div>
</div>

File diff suppressed because it is too large Load Diff