mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 全新的Vue3管理后台(admin-spa)和路由重构
🎨 新增功能: - 使用Vue3 + Vite构建的全新管理后台界面 - 支持Tab切换的API统计页面(统计查询/使用教程) - 优雅的胶囊式Tab切换设计 - 同步了PR #106的会话窗口管理功能 - 完整的响应式设计和骨架屏加载状态 🔧 路由调整: - 新版管理后台部署在 /admin-next/ 路径 - 将根路径 / 重定向到 /admin-next/api-stats - 将 /web 页面路由重定向到新版,保留 /web/auth/* 认证路由 - 将 /apiStats 页面路由重定向到新版,保留API端点 🗑️ 清理工作: - 删除旧版 web/admin/ 静态文件 - 删除旧版 web/apiStats/ 静态文件 - 清理相关的文件服务代码 🐛 修复问题: - 修复重定向循环问题 - 修复环境变量配置 - 修复路由404错误 - 优化构建配置 🚀 生成方式:使用 Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
923
web/admin-spa/src/views/ApiKeysView.vue
Normal file
923
web/admin-spa/src/views/ApiKeysView.vue
Normal file
@@ -0,0 +1,923 @@
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Token统计时间范围选择 -->
|
||||
<select
|
||||
v-model="apiKeyStatsTimeRange"
|
||||
@change="loadApiKeys()"
|
||||
class="form-input px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="today">今日</option>
|
||||
<option value="7days">最近7天</option>
|
||||
<option value="monthly">本月</option>
|
||||
<option value="all">全部时间</option>
|
||||
</select>
|
||||
<button
|
||||
@click.stop="openCreateApiKeyModal"
|
||||
class="btn btn-primary px-6 py-3 flex items-center gap-2"
|
||||
>
|
||||
<i class="fas fa-plus"></i>创建新 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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<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')">
|
||||
名称
|
||||
<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>
|
||||
</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>
|
||||
</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')">
|
||||
(费用
|
||||
<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>)
|
||||
</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')">
|
||||
创建时间
|
||||
<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>
|
||||
</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')">
|
||||
过期时间
|
||||
<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>
|
||||
</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">
|
||||
<!-- 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>
|
||||
</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-xs text-gray-500 mt-1">
|
||||
<span v-if="key.claudeAccountId">
|
||||
<i class="fas fa-link mr-1"></i>
|
||||
绑定: {{ getBoundAccountName(key.claudeAccountId) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-share-alt mr-1"></i>
|
||||
使用共享池
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-mono text-gray-600 bg-gray-50 px-3 py-1 rounded-lg">
|
||||
{{ (key.apiKey || '').substring(0, 20) }}...
|
||||
</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>
|
||||
{{ key.isActive ? '活跃' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ new Date(key.createdAt).toLocaleDateString() }}
|
||||
</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>
|
||||
<div v-else-if="isApiKeyExpiringSoon(key.expiresAt)" class="text-orange-600">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
{{ formatExpireDate(key.expiresAt) }}
|
||||
</div>
|
||||
<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>
|
||||
</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"
|
||||
title="复制统计页面链接"
|
||||
>
|
||||
<i class="fas fa-chart-bar mr-1"></i>统计
|
||||
</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"
|
||||
>
|
||||
<i class="fas fa-edit mr-1"></i>编辑
|
||||
</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"
|
||||
>
|
||||
<i class="fas fa-clock mr-1"></i>续期
|
||||
</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"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 模型统计展开区域 -->
|
||||
<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>
|
||||
</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>
|
||||
模型使用分布
|
||||
</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">
|
||||
{{ apiKeyModelStats[key.id].length }} 个模型
|
||||
</span>
|
||||
|
||||
<!-- API Keys日期筛选器 -->
|
||||
<div class="flex gap-1 items-center">
|
||||
<!-- 快捷日期选择 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded p-1">
|
||||
<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'
|
||||
]"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:disabled-date="disabledDate"
|
||||
:default-time="defaultTime"
|
||||
size="small"
|
||||
style="width: 280px;"
|
||||
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 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>
|
||||
<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="重置筛选条件并刷新"
|
||||
>
|
||||
<i class="fas fa-sync-alt text-xs"></i>
|
||||
<span class="text-xs">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 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>
|
||||
<span class="text-xs text-gray-500 bg-blue-50 px-2 py-1 rounded-full">{{ stat.requests }} 次请求</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
总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>
|
||||
费用:
|
||||
</span>
|
||||
<span class="font-semibold text-green-600">{{ calculateModelCost(stat) }}</span>
|
||||
</div>
|
||||
<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>
|
||||
输入:
|
||||
</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>
|
||||
输出:
|
||||
</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">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-save mr-1"></i>
|
||||
缓存创建:
|
||||
</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">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-download mr-1"></i>
|
||||
缓存读取:
|
||||
</span>
|
||||
<span class="font-medium">{{ formatNumber(stat.cacheReadTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<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>
|
||||
<div class="text-right mt-1">
|
||||
<span class="text-xs font-medium text-indigo-600">
|
||||
{{ calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 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>
|
||||
总计统计
|
||||
</span>
|
||||
<div class="flex gap-4 text-xs">
|
||||
<span class="text-gray-600">
|
||||
总请求: <span class="font-semibold text-gray-800">{{ apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.requests, 0) }}</span>
|
||||
</span>
|
||||
<span class="text-gray-600">
|
||||
总Token: <span class="font-semibold text-gray-800">{{ formatNumber(apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.allTokens, 0)) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框组件 -->
|
||||
<CreateApiKeyModal
|
||||
v-if="showCreateApiKeyModal"
|
||||
:accounts="accounts"
|
||||
@close="showCreateApiKeyModal = false"
|
||||
@success="handleCreateSuccess"
|
||||
/>
|
||||
|
||||
<EditApiKeyModal
|
||||
v-if="showEditApiKeyModal"
|
||||
:apiKey="editingApiKey"
|
||||
:accounts="accounts"
|
||||
@close="showEditApiKeyModal = false"
|
||||
@success="handleEditSuccess"
|
||||
/>
|
||||
|
||||
<RenewApiKeyModal
|
||||
v-if="showRenewApiKeyModal"
|
||||
:apiKey="renewingApiKey"
|
||||
@close="showRenewApiKeyModal = false"
|
||||
@success="handleRenewSuccess"
|
||||
/>
|
||||
|
||||
<NewApiKeyModal
|
||||
v-if="showNewApiKeyModal"
|
||||
:apiKeyData="newApiKeyData"
|
||||
@close="showNewApiKeyModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
|
||||
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
|
||||
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
|
||||
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
|
||||
|
||||
// 响应式数据
|
||||
const clientsStore = useClientsStore()
|
||||
const apiKeys = ref([])
|
||||
const apiKeysLoading = ref(false)
|
||||
const apiKeyStatsTimeRange = ref('today')
|
||||
const apiKeysSortBy = ref('')
|
||||
const apiKeysSortOrder = ref('asc')
|
||||
const expandedApiKeys = ref({})
|
||||
const apiKeyModelStats = ref({})
|
||||
const apiKeyDateFilters = ref({})
|
||||
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
||||
const accounts = ref({ claude: [], gemini: [] })
|
||||
|
||||
// 模态框状态
|
||||
const showCreateApiKeyModal = ref(false)
|
||||
const showEditApiKeyModal = ref(false)
|
||||
const showRenewApiKeyModal = ref(false)
|
||||
const showNewApiKeyModal = ref(false)
|
||||
const editingApiKey = ref(null)
|
||||
const renewingApiKey = ref(null)
|
||||
const newApiKeyData = ref(null)
|
||||
|
||||
// 计算排序后的API Keys
|
||||
const sortedApiKeys = computed(() => {
|
||||
if (!apiKeysSortBy.value) return apiKeys.value
|
||||
|
||||
const sorted = [...apiKeys.value].sort((a, b) => {
|
||||
let aVal = a[apiKeysSortBy.value]
|
||||
let bVal = b[apiKeysSortBy.value]
|
||||
|
||||
// 处理特殊排序字段
|
||||
if (apiKeysSortBy.value === 'status') {
|
||||
aVal = a.isActive ? 1 : 0
|
||||
bVal = b.isActive ? 1 : 0
|
||||
} else if (apiKeysSortBy.value === 'cost') {
|
||||
aVal = parseFloat(calculateApiKeyCost(a.usage).replace('$', ''))
|
||||
bVal = parseFloat(calculateApiKeyCost(b.usage).replace('$', ''))
|
||||
} else if (apiKeysSortBy.value === 'createdAt' || apiKeysSortBy.value === 'expiresAt') {
|
||||
aVal = aVal ? new Date(aVal).getTime() : 0
|
||||
bVal = bVal ? new Date(bVal).getTime() : 0
|
||||
}
|
||||
|
||||
if (aVal < bVal) return apiKeysSortOrder.value === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return apiKeysSortOrder.value === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
|
||||
return sorted
|
||||
})
|
||||
|
||||
// 加载账户列表
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const [claudeData, geminiData] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts')
|
||||
])
|
||||
|
||||
if (claudeData.success) {
|
||||
accounts.value.claude = claudeData.data || []
|
||||
}
|
||||
|
||||
if (geminiData.success) {
|
||||
accounts.value.gemini = geminiData.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载API Keys
|
||||
const loadApiKeys = async () => {
|
||||
apiKeysLoading.value = true
|
||||
try {
|
||||
const data = await apiClient.get(`/admin/api-keys?timeRange=${apiKeyStatsTimeRange.value}`)
|
||||
if (data.success) {
|
||||
apiKeys.value = data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载 API Keys 失败', 'error')
|
||||
} finally {
|
||||
apiKeysLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 排序API Keys
|
||||
const sortApiKeys = (field) => {
|
||||
if (apiKeysSortBy.value === field) {
|
||||
apiKeysSortOrder.value = apiKeysSortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
apiKeysSortBy.value = field
|
||||
apiKeysSortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (!num && num !== 0) return '0'
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 计算API Key费用
|
||||
const calculateApiKeyCost = (usage) => {
|
||||
if (!usage || !usage.total) return '$0.0000'
|
||||
const cost = usage.total.cost || 0
|
||||
return `$${cost.toFixed(4)}`
|
||||
}
|
||||
|
||||
// 获取绑定账户名称
|
||||
const getBoundAccountName = (accountId) => {
|
||||
if (!accountId) return '未知账户'
|
||||
|
||||
// 从Claude账户列表中查找
|
||||
const claudeAccount = accounts.value.claude.find(acc => acc.id === accountId)
|
||||
if (claudeAccount) {
|
||||
return claudeAccount.name
|
||||
}
|
||||
|
||||
// 从Gemini账户列表中查找
|
||||
const geminiAccount = accounts.value.gemini.find(acc => acc.id === accountId)
|
||||
if (geminiAccount) {
|
||||
return geminiAccount.name
|
||||
}
|
||||
|
||||
// 如果找不到,返回账户ID的前8位
|
||||
return `账户-${accountId.substring(0, 8)}`
|
||||
}
|
||||
|
||||
// 检查API Key是否过期
|
||||
const isApiKeyExpired = (expiresAt) => {
|
||||
if (!expiresAt) return false
|
||||
return new Date(expiresAt) < new Date()
|
||||
}
|
||||
|
||||
// 检查API Key是否即将过期
|
||||
const isApiKeyExpiringSoon = (expiresAt) => {
|
||||
if (!expiresAt || isApiKeyExpired(expiresAt)) return false
|
||||
const daysUntilExpiry = (new Date(expiresAt) - new Date()) / (1000 * 60 * 60 * 24)
|
||||
return daysUntilExpiry <= 7
|
||||
}
|
||||
|
||||
// 格式化过期日期
|
||||
const formatExpireDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 切换模型统计展开状态
|
||||
const toggleApiKeyModelStats = async (keyId) => {
|
||||
if (!expandedApiKeys.value[keyId]) {
|
||||
expandedApiKeys.value[keyId] = true
|
||||
// 初始化日期筛选器
|
||||
if (!apiKeyDateFilters.value[keyId]) {
|
||||
initApiKeyDateFilter(keyId)
|
||||
}
|
||||
// 加载模型统计数据
|
||||
await loadApiKeyModelStats(keyId, true)
|
||||
} else {
|
||||
expandedApiKeys.value[keyId] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 API Key 的模型统计
|
||||
const loadApiKeyModelStats = async (keyId, forceReload = false) => {
|
||||
if (!forceReload && apiKeyModelStats.value[keyId] && apiKeyModelStats.value[keyId].length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
try {
|
||||
let url = `/admin/api-keys/${keyId}/model-stats`
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filter.customStart && filter.customEnd) {
|
||||
params.append('startDate', filter.customStart)
|
||||
params.append('endDate', filter.customEnd)
|
||||
params.append('period', 'custom')
|
||||
} else {
|
||||
const period = filter.preset === 'today' ? 'daily' : 'monthly'
|
||||
params.append('period', period)
|
||||
}
|
||||
|
||||
url += '?' + params.toString()
|
||||
|
||||
const data = await apiClient.get(url)
|
||||
if (data.success) {
|
||||
apiKeyModelStats.value[keyId] = data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载模型统计失败', 'error')
|
||||
apiKeyModelStats.value[keyId] = []
|
||||
}
|
||||
}
|
||||
|
||||
// 计算API Key模型使用百分比
|
||||
const calculateApiKeyModelPercentage = (value, stats) => {
|
||||
const total = stats.reduce((sum, stat) => sum + (stat.allTokens || 0), 0)
|
||||
if (total === 0) return 0
|
||||
return Math.round((value / total) * 100)
|
||||
}
|
||||
|
||||
// 计算单个模型费用
|
||||
const calculateModelCost = (stat) => {
|
||||
// 优先使用后端返回的费用数据
|
||||
if (stat.formatted && stat.formatted.total) {
|
||||
return stat.formatted.total
|
||||
}
|
||||
|
||||
// 如果没有 formatted 数据,尝试使用 cost 字段
|
||||
if (stat.cost !== undefined) {
|
||||
return `$${stat.cost.toFixed(6)}`
|
||||
}
|
||||
|
||||
// 默认返回
|
||||
return '$0.000000'
|
||||
}
|
||||
|
||||
// 初始化API Key的日期筛选器
|
||||
const initApiKeyDateFilter = (keyId) => {
|
||||
const today = new Date()
|
||||
const startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - 6) // 7天前
|
||||
|
||||
apiKeyDateFilters.value[keyId] = {
|
||||
type: 'preset',
|
||||
preset: '7days',
|
||||
customStart: startDate.toISOString().split('T')[0],
|
||||
customEnd: today.toISOString().split('T')[0],
|
||||
customRange: null,
|
||||
presetOptions: [
|
||||
{ value: 'today', label: '今日', days: 1 },
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 获取API Key的日期筛选器状态
|
||||
const getApiKeyDateFilter = (keyId) => {
|
||||
if (!apiKeyDateFilters.value[keyId]) {
|
||||
initApiKeyDateFilter(keyId)
|
||||
}
|
||||
return apiKeyDateFilters.value[keyId]
|
||||
}
|
||||
|
||||
// 设置 API Key 日期预设
|
||||
const setApiKeyDateFilterPreset = (preset, keyId) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
filter.type = 'preset'
|
||||
filter.preset = preset
|
||||
|
||||
const option = filter.presetOptions.find(opt => opt.value === preset)
|
||||
if (option) {
|
||||
const today = new Date()
|
||||
const startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - (option.days - 1))
|
||||
|
||||
filter.customStart = startDate.toISOString().split('T')[0]
|
||||
filter.customEnd = today.toISOString().split('T')[0]
|
||||
|
||||
const formatDate = (date) => {
|
||||
return date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0') + ' 00:00:00'
|
||||
}
|
||||
|
||||
filter.customRange = [
|
||||
formatDate(startDate),
|
||||
formatDate(today)
|
||||
]
|
||||
}
|
||||
|
||||
loadApiKeyModelStats(keyId, true)
|
||||
}
|
||||
|
||||
// API Key 自定义日期范围变化
|
||||
const onApiKeyCustomDateRangeChange = (keyId, value) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
if (value && value.length === 2) {
|
||||
filter.type = 'custom'
|
||||
filter.preset = ''
|
||||
filter.customRange = value
|
||||
filter.customStart = value[0].split(' ')[0]
|
||||
filter.customEnd = value[1].split(' ')[0]
|
||||
|
||||
loadApiKeyModelStats(keyId, true)
|
||||
} else if (value === null) {
|
||||
// 清空时恢复默认7天
|
||||
setApiKeyDateFilterPreset('7days', keyId)
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用未来日期
|
||||
const disabledDate = (date) => {
|
||||
return date > new Date()
|
||||
}
|
||||
|
||||
// 重置API Key日期筛选器
|
||||
const resetApiKeyDateFilter = (keyId) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
// 重置为默认的7天
|
||||
filter.type = 'preset'
|
||||
filter.preset = '7days'
|
||||
|
||||
const today = new Date()
|
||||
const startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - 6)
|
||||
|
||||
filter.customStart = startDate.toISOString().split('T')[0]
|
||||
filter.customEnd = today.toISOString().split('T')[0]
|
||||
filter.customRange = null
|
||||
|
||||
// 重新加载数据
|
||||
loadApiKeyModelStats(keyId, true)
|
||||
showToast('已重置筛选条件并刷新数据', 'info')
|
||||
}
|
||||
|
||||
// 打开创建模态框
|
||||
const openCreateApiKeyModal = () => {
|
||||
showCreateApiKeyModal.value = true
|
||||
}
|
||||
|
||||
// 打开编辑模态框
|
||||
const openEditApiKeyModal = (apiKey) => {
|
||||
editingApiKey.value = apiKey
|
||||
showEditApiKeyModal.value = true
|
||||
}
|
||||
|
||||
// 打开续期模态框
|
||||
const openRenewApiKeyModal = (apiKey) => {
|
||||
renewingApiKey.value = apiKey
|
||||
showRenewApiKeyModal.value = true
|
||||
}
|
||||
|
||||
// 处理创建成功
|
||||
const handleCreateSuccess = (data) => {
|
||||
showCreateApiKeyModal.value = false
|
||||
newApiKeyData.value = data
|
||||
showNewApiKeyModal.value = true
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
// 处理编辑成功
|
||||
const handleEditSuccess = () => {
|
||||
showEditApiKeyModal.value = false
|
||||
showToast('API Key 更新成功', 'success')
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
// 处理续期成功
|
||||
const handleRenewSuccess = () => {
|
||||
showRenewApiKeyModal.value = false
|
||||
showToast('API Key 续期成功', 'success')
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
// 删除API Key
|
||||
const deleteApiKey = async (keyId) => {
|
||||
if (!confirm('确定要删除这个 API Key 吗?此操作不可恢复。')) return
|
||||
|
||||
try {
|
||||
const data = await apiClient.delete(`/admin/api-keys/${keyId}`)
|
||||
if (data.success) {
|
||||
showToast('API Key 已删除', 'success')
|
||||
loadApiKeys()
|
||||
} else {
|
||||
showToast(data.message || '删除失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('删除失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 复制API统计页面链接
|
||||
const copyApiStatsLink = (apiKey) => {
|
||||
// 构建统计页面的完整URL
|
||||
const baseUrl = window.location.origin
|
||||
const statsUrl = `${baseUrl}/admin/api-stats?apiId=${apiKey.id}`
|
||||
|
||||
// 使用传统的textarea方法复制到剪贴板
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = statsUrl
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, 99999) // 兼容移动端
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
showToast(`已复制统计页面链接`, 'success')
|
||||
} else {
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
console.log('统计页面链接:', statsUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('复制失败,请手动复制', 'error')
|
||||
console.error('复制错误:', err)
|
||||
console.log('统计页面链接:', statsUrl)
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 并行加载所有需要的数据
|
||||
await Promise.all([
|
||||
clientsStore.loadSupportedClients(),
|
||||
loadAccounts(),
|
||||
loadApiKeys()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-content {
|
||||
min-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.api-key-date-picker :deep(.el-input__inner) {
|
||||
@apply bg-white border-gray-300 focus:border-blue-500 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.api-key-date-picker :deep(.el-range-separator) {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user