feat: 优化 API Keys 页面的分组显示和使用统计展示

- 修复分组调度显示,正确展示分组名称
- 重新设计使用统计列,添加进度条显示每日费用和窗口限制
- 创建使用详情弹窗组件,展示完整统计信息
- 优化时间窗口限制显示,支持请求次数和Token双维度进度条
- 改进移动端自适应布局
- 修复 ESLint 警告,提升代码质量

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-08-04 10:31:59 +08:00
parent e95ca78942
commit 2ceac331dd
6 changed files with 733 additions and 240 deletions

View File

@@ -183,7 +183,9 @@
class="form-input flex-1"
required
>
<option value="">请选择分组</option>
<option value="">
请选择分组
</option>
<option
v-for="group in filteredGroups"
:key="group.id"
@@ -191,14 +193,19 @@
>
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
</option>
<option value="__new__">+ 新建分组</option>
<option value="__new__">
+ 新建分组
</option>
</select>
<button
type="button"
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
@click="refreshGroups"
>
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
<i
class="fas fa-sync-alt"
:class="{ 'animate-spin': loadingGroups }"
/>
</button>
</div>
</div>
@@ -561,7 +568,9 @@
class="form-input flex-1"
required
>
<option value="">请选择分组</option>
<option value="">
请选择分组
</option>
<option
v-for="group in filteredGroups"
:key="group.id"
@@ -569,14 +578,19 @@
>
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
</option>
<option value="__new__">+ 新建分组</option>
<option value="__new__">
+ 新建分组
</option>
</select>
<button
type="button"
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
@click="refreshGroups"
>
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
<i
class="fas fa-sync-alt"
:class="{ 'animate-spin': loadingGroups }"
/>
</button>
</div>
</div>

View File

@@ -38,7 +38,9 @@
v-if="showCreateForm"
class="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200"
>
<h4 class="text-lg font-semibold text-gray-900 mb-4">创建新分组</h4>
<h4 class="text-lg font-semibold text-gray-900 mb-4">
创建新分组
</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
@@ -113,7 +115,9 @@
class="text-center py-8"
>
<div class="loading-spinner-lg mx-auto mb-4" />
<p class="text-gray-500">加载中...</p>
<p class="text-gray-500">
加载中...
</p>
</div>
<div
@@ -121,7 +125,9 @@
class="text-center py-8 bg-gray-50 rounded-lg"
>
<i class="fas fa-layer-group text-4xl text-gray-300 mb-4" />
<p class="text-gray-500">暂无分组</p>
<p class="text-gray-500">
暂无分组
</p>
</div>
<div
@@ -135,8 +141,12 @@
>
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<h4 class="font-semibold text-gray-900">{{ group.name }}</h4>
<p class="text-sm text-gray-500 mt-1">{{ group.description || '暂无描述' }}</p>
<h4 class="font-semibold text-gray-900">
{{ group.name }}
</h4>
<p class="text-sm text-gray-500 mt-1">
{{ group.description || '暂无描述' }}
</p>
</div>
<div class="flex items-center gap-2 ml-4">
<span
@@ -194,7 +204,9 @@
>
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-gray-900">编辑分组</h3>
<h3 class="text-lg font-bold text-gray-900">
编辑分组
</h3>
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
@click="cancelEdit"

View File

@@ -0,0 +1,360 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 overflow-y-auto"
@click.self="close"
>
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- 背景遮罩 -->
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
@click="close"
/>
<!-- 模态框 -->
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<!-- 标题栏 -->
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 py-4">
<h3 class="text-lg font-semibold text-white flex items-center">
<i class="fas fa-chart-line mr-2" />
使用统计详情 - {{ apiKey.name }}
</h3>
</div>
<!-- 内容区 -->
<div class="px-6 py-4 max-h-[70vh] overflow-y-auto">
<!-- 总体统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<!-- 请求统计卡片 -->
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-700">总请求数</span>
<i class="fas fa-paper-plane text-blue-500" />
</div>
<div class="text-2xl font-bold text-gray-900">
{{ formatNumber(totalRequests) }}
</div>
<div class="text-xs text-gray-600 mt-1">
今日: {{ formatNumber(dailyRequests) }}
</div>
</div>
<!-- Token统计卡片 -->
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4 border border-green-200">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-700">总Token数</span>
<i class="fas fa-coins text-green-500" />
</div>
<div class="text-2xl font-bold text-gray-900">
{{ formatNumber(totalTokens) }}
</div>
<div class="text-xs text-gray-600 mt-1">
今日: {{ formatNumber(dailyTokens) }}
</div>
</div>
<!-- 费用统计卡片 -->
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-lg p-4 border border-yellow-200">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-700">总费用</span>
<i class="fas fa-dollar-sign text-yellow-600" />
</div>
<div class="text-2xl font-bold text-gray-900">
${{ totalCost.toFixed(4) }}
</div>
<div class="text-xs text-gray-600 mt-1">
今日: ${{ dailyCost.toFixed(4) }}
</div>
</div>
<!-- 平均统计卡片 -->
<div class="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-4 border border-purple-200">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium text-gray-700">平均速率</span>
<i class="fas fa-tachometer-alt text-purple-500" />
</div>
<div class="text-sm space-y-1">
<div class="flex justify-between">
<span class="text-gray-600">RPM:</span>
<span class="font-semibold">{{ rpm }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">TPM:</span>
<span class="font-semibold">{{ tpm }}</span>
</div>
</div>
</div>
</div>
<!-- Token详细分布 -->
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center">
<i class="fas fa-chart-pie text-indigo-500 mr-2" />
Token 使用分布
</h4>
<div class="bg-gray-50 rounded-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-arrow-down text-green-500 mr-2" />
<span class="text-sm text-gray-600">输入 Token</span>
</div>
<span class="text-sm font-semibold text-gray-900">
{{ formatNumber(inputTokens) }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-arrow-up text-blue-500 mr-2" />
<span class="text-sm text-gray-600">输出 Token</span>
</div>
<span class="text-sm font-semibold text-gray-900">
{{ formatNumber(outputTokens) }}
</span>
</div>
<div
v-if="cacheCreateTokens > 0"
class="flex items-center justify-between"
>
<div class="flex items-center">
<i class="fas fa-save text-purple-500 mr-2" />
<span class="text-sm text-gray-600">缓存创建 Token</span>
</div>
<span class="text-sm font-semibold text-purple-600">
{{ formatNumber(cacheCreateTokens) }}
</span>
</div>
<div
v-if="cacheReadTokens > 0"
class="flex items-center justify-between"
>
<div class="flex items-center">
<i class="fas fa-download text-purple-500 mr-2" />
<span class="text-sm text-gray-600">缓存读取 Token</span>
</div>
<span class="text-sm font-semibold text-purple-600">
{{ formatNumber(cacheReadTokens) }}
</span>
</div>
</div>
</div>
<!-- 限制信息 -->
<div
v-if="hasLimits"
class="mb-6"
>
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center">
<i class="fas fa-shield-alt text-red-500 mr-2" />
限制设置
</h4>
<div class="bg-gray-50 rounded-lg p-4 space-y-3">
<div
v-if="apiKey.dailyCostLimit > 0"
class="space-y-2"
>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">每日费用限制</span>
<span class="font-semibold text-gray-900">
${{ apiKey.dailyCostLimit.toFixed(2) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="dailyCostPercentage >= 100 ? 'bg-red-500' : dailyCostPercentage >= 80 ? 'bg-yellow-500' : 'bg-green-500'"
:style="{ width: Math.min(dailyCostPercentage, 100) + '%' }"
/>
</div>
<div class="text-xs text-gray-500 text-right">
已使用 {{ dailyCostPercentage.toFixed(1) }}%
</div>
</div>
<div
v-if="apiKey.concurrencyLimit > 0"
class="flex items-center justify-between text-sm"
>
<span class="text-gray-600">并发限制</span>
<span class="font-semibold text-purple-600">
{{ apiKey.currentConcurrency || 0 }} / {{ apiKey.concurrencyLimit }}
</span>
</div>
<div
v-if="apiKey.rateLimitWindow > 0"
class="space-y-2"
>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">时间窗口</span>
<span class="font-semibold text-indigo-600">
{{ apiKey.rateLimitWindow }} 分钟
</span>
</div>
<!-- 请求次数限制 -->
<div
v-if="apiKey.rateLimitRequests > 0"
class="space-y-1"
>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">请求限制</span>
<span class="font-semibold text-gray-900">
{{ apiKey.currentWindowRequests || 0 }} / {{ apiKey.rateLimitRequests }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="windowRequestProgressColor"
:style="{ width: windowRequestProgress + '%' }"
/>
</div>
</div>
<!-- Token使用量限制 -->
<div
v-if="apiKey.tokenLimit > 0"
class="space-y-1"
>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Token限制</span>
<span class="font-semibold text-gray-900">
{{ formatTokenCount(apiKey.currentWindowTokens || 0) }} / {{ formatTokenCount(apiKey.tokenLimit) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="windowTokenProgressColor"
:style="{ width: windowTokenProgress + '%' }"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="bg-gray-50 px-6 py-3 flex justify-end">
<button
type="button"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400"
@click="close"
>
关闭
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
show: {
type: Boolean,
required: true
},
apiKey: {
type: Object,
required: true
}
})
const emit = defineEmits(['close'])
// 计算属性
const totalRequests = computed(() => (props.apiKey.usage?.total?.requests) || 0)
const dailyRequests = computed(() => (props.apiKey.usage?.daily?.requests) || 0)
const totalTokens = computed(() => (props.apiKey.usage?.total?.tokens) || 0)
const dailyTokens = computed(() => (props.apiKey.usage?.daily?.tokens) || 0)
const totalCost = computed(() => (props.apiKey.usage?.total?.cost) || 0)
const dailyCost = computed(() => props.apiKey.dailyCost || 0)
const inputTokens = computed(() => (props.apiKey.usage?.total?.inputTokens) || 0)
const outputTokens = computed(() => (props.apiKey.usage?.total?.outputTokens) || 0)
const cacheCreateTokens = computed(() => (props.apiKey.usage?.total?.cacheCreateTokens) || 0)
const cacheReadTokens = computed(() => (props.apiKey.usage?.total?.cacheReadTokens) || 0)
const rpm = computed(() => (props.apiKey.usage?.averages?.rpm) || 0)
const tpm = computed(() => (props.apiKey.usage?.averages?.tpm) || 0)
const hasLimits = computed(() => {
return props.apiKey.dailyCostLimit > 0 ||
props.apiKey.concurrencyLimit > 0 ||
props.apiKey.rateLimitWindow > 0 ||
props.apiKey.tokenLimit > 0
})
const dailyCostPercentage = computed(() => {
if (!props.apiKey.dailyCostLimit || props.apiKey.dailyCostLimit === 0) return 0
return (dailyCost.value / props.apiKey.dailyCostLimit) * 100
})
// 窗口请求进度
const windowRequestProgress = computed(() => {
if (!props.apiKey.rateLimitRequests || props.apiKey.rateLimitRequests === 0) return 0
const percentage = ((props.apiKey.currentWindowRequests || 0) / props.apiKey.rateLimitRequests) * 100
return Math.min(percentage, 100)
})
const windowRequestProgressColor = computed(() => {
const progress = windowRequestProgress.value
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-blue-500'
})
// 窗口Token进度
const windowTokenProgress = computed(() => {
if (!props.apiKey.tokenLimit || props.apiKey.tokenLimit === 0) return 0
const percentage = ((props.apiKey.currentWindowTokens || 0) / props.apiKey.tokenLimit) * 100
return Math.min(percentage, 100)
})
const windowTokenProgressColor = computed(() => {
const progress = windowTokenProgress.value
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-purple-500'
})
// 方法
const formatNumber = (num) => {
if (!num && num !== 0) return '0'
return num.toLocaleString('zh-CN')
}
// 格式化Token数量使用K/M单位
const formatTokenCount = (count) => {
if (count >= 1000000) {
return (count / 1000000).toFixed(1) + 'M'
} else if (count >= 1000) {
return (count / 1000).toFixed(1) + 'K'
}
return count.toString()
}
const close = () => {
emit('close')
}
</script>
<style scoped>
/* 添加过渡动画 */
.transform {
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -38,8 +38,12 @@
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
@change="filterByGroup"
>
<option value="all">所有账户</option>
<option value="ungrouped">未分组账户</option>
<option value="all">
所有账户
</option>
<option value="ungrouped">
未分组账户
</option>
<option
v-for="group in accountGroups"
:key="group.id"
@@ -177,7 +181,10 @@
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="text-sm font-semibold text-gray-900 truncate" :title="account.name">
<div
class="text-sm font-semibold text-gray-900 truncate"
:title="account.name"
>
{{ account.name }}
</div>
<span
@@ -206,7 +213,10 @@
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
</span>
</div>
<div class="text-xs text-gray-500 truncate" :title="account.id">
<div
class="text-xs text-gray-500 truncate"
:title="account.id"
>
{{ account.id }}
</div>
</div>
@@ -221,7 +231,7 @@
>
<i class="fas fa-robot text-yellow-700 text-xs" />
<span class="text-xs font-semibold text-yellow-800">Gemini</span>
<span class="w-px h-4 bg-yellow-300 mx-1"></span>
<span class="w-px h-4 bg-yellow-300 mx-1" />
<span class="text-xs font-medium text-yellow-700">
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
</span>
@@ -232,7 +242,7 @@
>
<i class="fas fa-terminal text-purple-700 text-xs" />
<span class="text-xs font-semibold text-purple-800">Console</span>
<span class="w-px h-4 bg-purple-300 mx-1"></span>
<span class="w-px h-4 bg-purple-300 mx-1" />
<span class="text-xs font-medium text-purple-700">API Key</span>
</div>
<div
@@ -241,7 +251,7 @@
>
<i class="fas fa-brain text-indigo-700 text-xs" />
<span class="text-xs font-semibold text-indigo-800">Claude</span>
<span class="w-px h-4 bg-indigo-300 mx-1"></span>
<span class="w-px h-4 bg-indigo-300 mx-1" />
<span class="text-xs font-medium text-indigo-700">
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
</span>
@@ -615,8 +625,8 @@
:class="account.schedulable
? 'text-gray-600 bg-gray-50 hover:bg-gray-100'
: 'text-green-600 bg-green-50 hover:bg-green-100'"
@click="toggleSchedulable(account)"
:disabled="account.isTogglingSchedulable"
@click="toggleSchedulable(account)"
>
<i :class="['fas', account.schedulable ? 'fa-pause' : 'fa-play']" />
{{ account.schedulable ? '暂停' : '启用' }}

View File

@@ -185,14 +185,23 @@
<i class="fas fa-key text-white text-xs" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-900 truncate" :title="key.name">
<div
class="text-sm font-semibold text-gray-900 truncate"
:title="key.name"
>
{{ key.name }}
</div>
<div class="text-xs text-gray-500 truncate" :title="key.id">
<div
class="text-xs text-gray-500 truncate"
:title="key.id"
>
{{ key.id }}
</div>
<div class="text-xs text-gray-500 mt-1 truncate">
<span v-if="key.claudeAccountId" :title="`绑定: ${getBoundAccountName(key.claudeAccountId)}`">
<span
v-if="key.claudeAccountId"
:title="`绑定: ${getBoundAccountName(key.claudeAccountId)}`"
>
<i class="fas fa-link mr-1" />
{{ getBoundAccountName(key.claudeAccountId) }}
</span>
@@ -232,98 +241,100 @@
</span>
</td>
<td class="px-3 py-4">
<div class="space-y-1">
<!-- 请求统计 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">请求数:</span>
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }}</span>
</div>
<!-- Token统计 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">Token:</span>
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }}</span>
</div>
<!-- 费用统计 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">费用:</span>
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
</div>
<!-- 每日费用限制 -->
<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) }}
</span>
</div>
<!-- 并发限制 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">并发限制:</span>
<span class="font-medium text-purple-600">{{ key.concurrencyLimit > 0 ? key.concurrencyLimit : '无限制' }}</span>
</div>
<!-- 当前并发数 -->
<div class="flex justify-between text-sm">
<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>
</div>
<!-- 时间窗口限流 -->
<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"
>
<span class="text-gray-600">请求限制:</span>
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} /窗口</span>
</div>
<!-- 输入/输出Token -->
<div class="flex justify-between text-xs text-gray-500">
<span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
<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"
>
<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>
<!-- RPM/TPM -->
<div class="flex justify-between text-xs text-blue-600">
<span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span>
<span>TPM: {{ (key.usage && key.usage.averages && key.usage.averages.tpm) || 0 }}</span>
</div>
<!-- 今日统计 -->
<div class="pt-1 border-t border-gray-100">
<div class="flex justify-between text-xs text-green-600">
<span>今日: {{ formatNumber((key.usage && key.usage.daily && key.usage.daily.requests) || 0) }}</span>
<span>{{ formatNumber((key.usage && key.usage.daily && key.usage.daily.tokens) || 0) }}T</span>
<div class="space-y-2">
<!-- 今日使用统计 -->
<div class="mb-2">
<div class="flex justify-between items-center text-sm mb-1">
<span class="text-gray-600">今日请求</span>
<span class="font-semibold text-gray-900">{{ formatNumber((key.usage?.daily?.requests) || 0) }}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600">今日费用</span>
<span class="font-semibold text-green-600">${{ (key.dailyCost || 0).toFixed(4) }}</span>
</div>
</div>
<!-- 模型分布按钮 -->
<div class="pt-2">
<button
v-if="key && key.id"
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
@click="toggleApiKeyModelStats(key.id)"
<!-- 每日费用限制进度条 -->
<div
v-if="key.dailyCostLimit > 0"
class="space-y-1"
>
<div class="flex justify-between items-center text-xs">
<span class="text-gray-500">费用限额</span>
<span class="text-gray-700">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getDailyCostProgressColor(key)"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getDailyCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- 时间窗口限制进度条 -->
<div
v-if="key.rateLimitWindow > 0"
class="space-y-1"
>
<div class="flex justify-between items-center text-xs">
<span class="text-gray-500">窗口限制</span>
<span class="text-gray-700">
{{ key.rateLimitWindow }}分钟
</span>
</div>
<!-- 请求次数限制 -->
<div
v-if="key.rateLimitRequests > 0"
class="space-y-0.5"
>
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']" />
模型使用分布
<div class="flex justify-between items-center text-xs">
<span class="text-gray-400">请求</span>
<span class="text-gray-600">
{{ key.currentWindowRequests || 0 }}/{{ key.rateLimitRequests }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1">
<div
:class="getWindowRequestProgressColor(key)"
class="h-1 rounded-full transition-all duration-300"
:style="{ width: getWindowRequestProgress(key) + '%' }"
/>
</div>
</div>
<!-- Token使用量限制 -->
<div
v-if="key.tokenLimit > 0"
class="space-y-0.5"
>
<div class="flex justify-between items-center text-xs">
<span class="text-gray-400">Token</span>
<span class="text-gray-600">
{{ formatTokenCount(key.currentWindowTokens || 0) }}/{{ formatTokenCount(key.tokenLimit) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1">
<div
:class="getWindowTokenProgressColor(key)"
class="h-1 rounded-full transition-all duration-300"
:style="{ width: getWindowTokenProgress(key) + '%' }"
/>
</div>
</div>
</div>
<!-- 查看详情按钮 -->
<div class="pt-1">
<button
class="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1 w-full justify-center py-1 hover:bg-blue-50 rounded transition-colors"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line" />
查看详细统计
</button>
</div>
</div>
@@ -373,6 +384,15 @@
</td>
<td class="px-3 py-4 whitespace-nowrap text-sm">
<div class="flex gap-1">
<button
v-if="key && key.id"
class="text-indigo-600 hover:text-indigo-900 font-medium hover:bg-indigo-50 px-2 py-1 rounded transition-colors text-xs"
title="模型使用分布"
@click="toggleApiKeyModelStats(key.id)"
>
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down']" />
<span class="hidden xl:inline ml-1">模型</span>
</button>
<button
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-2 py-1 rounded transition-colors text-xs"
title="复制统计页面链接"
@@ -472,10 +492,10 @@
: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"
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
/>
</div>
</div>
@@ -666,25 +686,104 @@
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<p class="text-xs text-gray-500">
使用量
</p>
<p class="text-sm font-semibold text-gray-900">
{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }} 次
</p>
<p class="text-xs text-gray-500 mt-0.5">
{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }} tokens
</p>
<div class="space-y-2 mb-3">
<!-- 今日使用 -->
<div class="bg-gray-50 rounded-lg p-3">
<div class="flex justify-between items-center mb-2">
<span class="text-xs text-gray-600">今日使用</span>
<button
class="text-xs text-blue-600 hover:text-blue-800"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line mr-1" />详情
</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-sm font-semibold text-gray-900">
{{ formatNumber((key.usage?.daily?.requests) || 0) }} 次
</p>
<p class="text-xs text-gray-500">
请求
</p>
</div>
<div>
<p class="text-sm font-semibold text-green-600">
${{ (key.dailyCost || 0).toFixed(4) }}
</p>
<p class="text-xs text-gray-500">
费用
</p>
</div>
</div>
</div>
<div>
<p class="text-xs text-gray-500">
费用
</p>
<p class="text-sm font-semibold text-green-600">
{{ calculateApiKeyCost(key.usage) }}
</p>
<!-- 限制进度 -->
<div
v-if="key.dailyCostLimit > 0"
class="space-y-1"
>
<div class="flex justify-between items-center text-xs">
<span class="text-gray-500">每日费用限额</span>
<span class="text-gray-700">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
:class="getDailyCostProgressColor(key)"
class="h-2 rounded-full transition-all duration-300"
:style="{ width: getDailyCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- 移动端时间窗口限制 -->
<div
v-if="key.rateLimitWindow > 0 && (key.rateLimitRequests > 0 || key.tokenLimit > 0)"
class="space-y-1"
>
<div class="text-xs text-gray-500 mb-1">
窗口限制 ({{ key.rateLimitWindow }}分钟)
</div>
<div
v-if="key.rateLimitRequests > 0"
class="flex items-center gap-2"
>
<span class="text-xs text-gray-500 w-10">请求</span>
<div class="flex-1">
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowRequestProgressColor(key)"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getWindowRequestProgress(key) + '%' }"
/>
</div>
</div>
<span class="text-xs text-gray-600 w-16 text-right">
{{ key.currentWindowRequests || 0 }}/{{ key.rateLimitRequests }}
</span>
</div>
<div
v-if="key.tokenLimit > 0"
class="flex items-center gap-2"
>
<span class="text-xs text-gray-500 w-10">Token</span>
<div class="flex-1">
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowTokenProgressColor(key)"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getWindowTokenProgress(key) + '%' }"
/>
</div>
</div>
<span class="text-xs text-gray-600 w-16 text-right">
{{ formatTokenCount(key.currentWindowTokens || 0) }}/{{ formatTokenCount(key.tokenLimit) }}
</span>
</div>
</div>
</div>
@@ -720,10 +819,10 @@
<div class="flex gap-2 mt-3 pt-3 border-t border-gray-100">
<button
class="flex-1 px-3 py-2 text-xs text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors flex items-center justify-center gap-1"
@click="toggleExpanded(key.id)"
@click="showUsageDetails(key)"
>
<i :class="['fas', expandedKeys.includes(key.id) ? 'fa-chevron-up' : 'fa-chevron-down']" />
{{ expandedKeys.includes(key.id) ? '收起' : '详情' }}
<i class="fas fa-chart-line" />
查看详情
</button>
<button
class="flex-1 px-3 py-2 text-xs text-gray-600 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
@@ -747,77 +846,6 @@
<i class="fas fa-trash" />
</button>
</div>
<!-- 展开的详细统计 -->
<div
v-if="expandedKeys.includes(key.id)"
class="mt-3 pt-3 border-t border-gray-100"
>
<h5 class="text-xs font-semibold text-gray-700 mb-2">
详细信息
</h5>
<!-- 更多统计数据 -->
<div class="space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-gray-600">并发限制:</span>
<span class="font-medium text-purple-600">{{ key.concurrencyLimit > 0 ? key.concurrencyLimit : '无限制' }}</span>
</div>
<div class="flex justify-between">
<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>
</div>
<div v-if="key.dailyCostLimit > 0" class="flex justify-between">
<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) }}
</span>
</div>
<div v-if="key.rateLimitWindow > 0" class="flex justify-between">
<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">
<span class="text-gray-600">请求限制:</span>
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} 次/窗口</span>
</div>
<!-- Token 细节 -->
<div class="pt-2 mt-2 border-t border-gray-100">
<div class="flex justify-between">
<span class="text-gray-600">输入 Token:</span>
<span class="font-medium">{{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">输出 Token:</span>
<span class="font-medium">{{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
</div>
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0" class="flex justify-between">
<span class="text-gray-600">缓存创建:</span>
<span class="font-medium text-purple-600">{{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
</div>
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0" class="flex justify-between">
<span class="text-gray-600">缓存读取:</span>
<span class="font-medium text-purple-600">{{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
</div>
</div>
<!-- 今日统计 -->
<div class="pt-2 mt-2 border-t border-gray-100">
<div class="flex justify-between">
<span class="text-gray-600">今日请求:</span>
<span class="font-medium text-green-600">{{ formatNumber((key.usage && key.usage.daily && key.usage.daily.requests) || 0) }} 次</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">今日 Token:</span>
<span class="font-medium text-green-600">{{ formatNumber((key.usage && key.usage.daily && key.usage.daily.tokens) || 0) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -959,6 +987,13 @@
@close="closeExpiryEdit"
@save="handleSaveExpiry"
/>
<!-- 使用详情弹窗 -->
<UsageDetailModal
:show="showUsageDetailModal"
:api-key="selectedApiKeyForDetail || {}"
@close="showUsageDetailModal = false"
/>
</div>
</template>
@@ -973,6 +1008,7 @@ import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
// 响应式数据
const clientsStore = useClientsStore()
@@ -988,13 +1024,13 @@ const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23,
const accounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
const editingExpiryKey = ref(null)
const expiryEditModalRef = ref(null)
const showUsageDetailModal = ref(false)
const selectedApiKeyForDetail = ref(null)
// 标签相关
const selectedTagFilter = ref('')
const availableTags = ref([])
// 移动端展开状态
const expandedKeys = ref([])
// 分页相关
const currentPage = ref(1)
@@ -1183,16 +1219,36 @@ const calculateApiKeyCost = (usage) => {
const getBoundAccountName = (accountId) => {
if (!accountId) return '未知账户'
// 检查是否是分组
if (accountId.startsWith('group:')) {
const groupId = accountId.substring(6) // 移除 'group:' 前缀
// 从Claude分组中查找
const claudeGroup = accounts.value.claudeGroups.find(g => g.id === groupId)
if (claudeGroup) {
return `分组-${claudeGroup.name}`
}
// 从Gemini分组中查找
const geminiGroup = accounts.value.geminiGroups.find(g => g.id === groupId)
if (geminiGroup) {
return `分组-${geminiGroup.name}`
}
// 如果找不到分组返回分组ID的前8位
return `分组-${groupId.substring(0, 8)}`
}
// 从Claude账户列表中查找
const claudeAccount = accounts.value.claude.find(acc => acc.id === accountId)
if (claudeAccount) {
return claudeAccount.name
return `账户-${claudeAccount.name}`
}
// 从Gemini账户列表中查找
const geminiAccount = accounts.value.gemini.find(acc => acc.id === accountId)
if (geminiAccount) {
return geminiAccount.name
return `账户-${geminiAccount.name}`
}
// 如果找不到返回账户ID的前8位
@@ -1548,15 +1604,6 @@ const handleSaveExpiry = async ({ keyId, expiresAt }) => {
}
}
// 切换移动端卡片展开状态
const toggleExpanded = (keyId) => {
const index = expandedKeys.value.indexOf(keyId)
if (index > -1) {
expandedKeys.value.splice(index, 1)
} else {
expandedKeys.value.push(keyId)
}
}
// 格式化日期时间
const formatDate = (dateString) => {
@@ -1571,26 +1618,68 @@ const formatDate = (dateString) => {
}).replace(/\//g, '-')
}
// 显示API Key详情
const showApiKey = async (apiKey) => {
try {
// 重新获取API Key的完整信息包含实际的key值
const response = await apiClient.get(`/admin/api-keys/${apiKey.id}`)
if (response.success && response.data) {
newApiKeyData.value = {
...response.data,
key: response.data.key || response.data.apiKey // 兼容不同的字段名
}
showNewApiKeyModal.value = true
} else {
showToast('获取API Key信息失败', 'error')
}
} catch (error) {
console.error('Error fetching API key:', error)
showToast('获取API Key信息失败', 'error')
}
// 获取每日费用进度
const getDailyCostProgress = (key) => {
if (!key.dailyCostLimit || key.dailyCostLimit === 0) return 0
const percentage = ((key.dailyCost || 0) / key.dailyCostLimit) * 100
return Math.min(percentage, 100)
}
// 获取每日费用进度条颜色
const getDailyCostProgressColor = (key) => {
const progress = getDailyCostProgress(key)
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 显示使用详情
const showUsageDetails = (apiKey) => {
selectedApiKeyForDetail.value = apiKey
showUsageDetailModal.value = true
}
// 格式化Token数量使用K/M单位
const formatTokenCount = (count) => {
if (count >= 1000000) {
return (count / 1000000).toFixed(1) + 'M'
} else if (count >= 1000) {
return (count / 1000).toFixed(1) + 'K'
}
return count.toString()
}
// 获取窗口请求进度
const getWindowRequestProgress = (key) => {
if (!key.rateLimitRequests || key.rateLimitRequests === 0) return 0
const percentage = ((key.currentWindowRequests || 0) / key.rateLimitRequests) * 100
return Math.min(percentage, 100)
}
// 获取窗口请求进度条颜色
const getWindowRequestProgressColor = (key) => {
const progress = getWindowRequestProgress(key)
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-blue-500'
}
// 获取窗口Token进度
const getWindowTokenProgress = (key) => {
if (!key.tokenLimit || key.tokenLimit === 0) return 0
const percentage = ((key.currentWindowTokens || 0) / key.tokenLimit) * 100
return Math.min(percentage, 100)
}
// 获取窗口Token进度条颜色
const getWindowTokenProgressColor = (key) => {
const progress = getWindowTokenProgress(key)
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-purple-500'
}
// 监听筛选条件变化,重置页码
watch([selectedTagFilter, apiKeyStatsTimeRange], () => {
currentPage.value = 1

View File

@@ -180,8 +180,12 @@
<i class="fas fa-font text-white text-sm" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900">网站名称</h4>
<p class="text-xs text-gray-500">品牌标识</p>
<h4 class="text-sm font-semibold text-gray-900">
网站名称
</h4>
<p class="text-xs text-gray-500">
品牌标识
</p>
</div>
</div>
<div class="space-y-2">
@@ -205,8 +209,12 @@
<i class="fas fa-image text-white text-sm" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900">网站图标</h4>
<p class="text-xs text-gray-500">Favicon</p>
<h4 class="text-sm font-semibold text-gray-900">
网站图标
</h4>
<p class="text-xs text-gray-500">
Favicon
</p>
</div>
</div>
<div class="space-y-3">