Files
claude-relay-service/web/admin-spa/src/views/ApiKeysView.vue
Edric Li 8f43b9367b feat: 优化Excel导出功能,专注用量数据统计
- 简化导出内容,仅包含用量相关数据
- 保留API Key名称和所有者信息
- 导出详细的分模型用量统计:
  * 今日各模型请求数、费用、输入/输出/总Token
  * 累计各模型请求数、费用、输入/输出/总Token
  * 根据时间筛选条件导出对应周期的模型统计
- 文件名包含时间筛选条件,便于识别数据范围
- 动态设置列宽,优化Excel显示效果
- 移除冗余的配置信息,专注核心用量数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-07 22:18:34 +08:00

3524 lines
141 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="tab-content">
<div class="card p-4 sm:p-6">
<div class="mb-4 flex flex-col gap-4 sm:mb-6">
<div>
<h3 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100 sm:mb-2 sm:text-xl">
API Keys 管理
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
管理和监控您的 API 密钥
</p>
</div>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav aria-label="Tabs" class="-mb-px flex space-x-8">
<button
:class="[
'whitespace-nowrap border-b-2 px-1 py-2 text-sm font-medium',
activeTab === 'active'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300'
]"
@click="activeTab = 'active'"
>
活跃 API Keys
<span
v-if="apiKeys.length > 0"
class="ml-2 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-900 dark:bg-gray-700 dark:text-gray-100"
>
{{ apiKeys.length }}
</span>
</button>
<button
:class="[
'whitespace-nowrap border-b-2 px-1 py-2 text-sm font-medium',
activeTab === 'deleted'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300'
]"
@click="loadDeletedApiKeys"
>
已删除 API Keys
<span
v-if="deletedApiKeys.length > 0"
class="ml-2 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-900 dark:bg-gray-700 dark:text-gray-100"
>
{{ deletedApiKeys.length }}
</span>
</button>
</nav>
</div>
<!-- Tab Content -->
<!-- 活跃 API Keys Tab Panel -->
<div v-if="activeTab === 'active'" class="tab-panel">
<!-- 工具栏区域 - 添加 mb-4 增加与表格的间距 -->
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<!-- 筛选器组 -->
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<!-- 时间范围筛选 -->
<div class="group relative min-w-[140px]">
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-purple-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<CustomDropdown
v-model="globalDateFilter.preset"
icon="fa-calendar-alt"
icon-color="text-blue-500"
:options="timeRangeDropdownOptions"
placeholder="选择时间范围"
@change="handleTimeRangeChange"
/>
</div>
<!-- 自定义日期范围选择器 - 在选择自定义时显示 -->
<div v-if="globalDateFilter.type === 'custom'" class="flex items-center">
<el-date-picker
class="api-key-date-picker custom-date-range-picker"
:clearable="true"
:default-time="defaultTime"
:disabled-date="disabledDate"
end-placeholder="结束日期"
format="YYYY-MM-DD HH:mm:ss"
:model-value="globalDateFilter.customRange"
range-separator=""
size="small"
start-placeholder="开始日期"
style="width: 320px"
type="datetimerange"
:unlink-panels="false"
value-format="YYYY-MM-DD HH:mm:ss"
@update:model-value="onGlobalCustomDateRangeChange"
/>
</div>
<!-- 标签筛选器 -->
<div class="group relative min-w-[140px]">
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<div class="relative">
<CustomDropdown
v-model="selectedTagFilter"
icon="fa-tags"
icon-color="text-purple-500"
:options="tagOptions"
placeholder="所有标签"
@change="currentPage = 1"
/>
<span
v-if="selectedTagFilter"
class="absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-purple-500 text-xs text-white shadow-sm"
>
{{ selectedTagCount }}
</span>
</div>
</div>
<!-- 搜索框 -->
<div class="group relative min-w-[200px]">
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-cyan-500 to-teal-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<div class="relative flex items-center">
<input
v-model="searchKeyword"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 pl-9 text-sm text-gray-700 placeholder-gray-400 shadow-sm transition-all duration-200 hover:border-gray-300 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500 dark:hover:border-gray-500"
:placeholder="isLdapEnabled ? '搜索名称或所有者...' : '搜索名称...'"
type="text"
@input="currentPage = 1"
/>
<i class="fas fa-search absolute left-3 text-sm text-cyan-500" />
<button
v-if="searchKeyword"
class="absolute right-2 flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
@click="clearSearch"
>
<i class="fas fa-times text-xs" />
</button>
</div>
</div>
<!-- 刷新按钮 -->
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:border-gray-500 sm:w-auto"
:disabled="apiKeysLoading"
@click="loadApiKeys()"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-green-500 to-teal-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i
:class="[
'fas relative text-green-500',
apiKeysLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt'
]"
/>
<span class="relative">刷新</span>
</button>
<!-- 选择/取消选择按钮 -->
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@click="toggleSelectionMode"
>
<i :class="showCheckboxes ? 'fas fa-times' : 'fas fa-check-square'"></i>
<span>{{ showCheckboxes ? '取消选择' : '选择' }}</span>
</button>
<!-- 导出数据按钮 -->
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:border-gray-500 sm:w-auto"
@click="exportToExcel"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-emerald-500 to-green-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-file-excel relative text-emerald-500" />
<span class="relative">导出数据</span>
</button>
<!-- 批量编辑按钮 - 移到刷新按钮旁边 -->
<button
v-if="selectedApiKeys.length > 0"
class="group relative flex items-center justify-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 shadow-sm transition-all duration-200 hover:border-blue-300 hover:bg-blue-100 hover:shadow-md dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 sm:w-auto"
@click="openBatchEditModal()"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-edit relative text-blue-600 dark:text-blue-400" />
<span class="relative">编辑选中 ({{ selectedApiKeys.length }})</span>
</button>
<!-- 批量删除按钮 - 移到刷新按钮旁边 -->
<button
v-if="selectedApiKeys.length > 0"
class="group relative flex items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition-all duration-200 hover:border-red-300 hover:bg-red-100 hover:shadow-md dark:border-red-700 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 sm:w-auto"
@click="batchDeleteApiKeys()"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-red-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-trash relative text-red-600 dark:text-red-400" />
<span class="relative">删除选中 ({{ selectedApiKeys.length }})</span>
</button>
</div>
<!-- 创建按钮 - 独立在右侧 -->
<button
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all duration-200 hover:from-blue-600 hover:to-blue-700 hover:shadow-lg sm:w-auto"
@click.stop="openCreateApiKeyModal"
>
<i class="fas fa-plus"></i>
<span>创建新 Key</span>
</button>
</div>
<div v-if="apiKeysLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">正在加载 API Keys...</p>
</div>
<div v-else-if="apiKeys.length === 0" class="py-12 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700"
>
<i class="fas fa-key text-xl text-gray-400" />
</div>
<p class="text-lg text-gray-500 dark:text-gray-400">暂无 API Keys</p>
<p class="mt-2 text-sm text-gray-400">点击上方按钮创建您的第一个 API Key</p>
</div>
<!-- 桌面端表格视图 -->
<div v-else class="table-wrapper hidden md:block">
<div class="table-container">
<table class="w-full table-fixed">
<thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
<tr>
<th v-if="shouldShowCheckboxes" class="w-[50px] px-3 py-4 text-left">
<div class="flex items-center">
<input
v-model="selectAllChecked"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
:indeterminate="isIndeterminate"
type="checkbox"
@change="handleSelectAll"
/>
</div>
</th>
<th
class="w-[20%] min-w-[160px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('name')"
>
名称
<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="w-[10%] min-w-[90px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
账号
</th>
<th
class="w-[10%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
标签
</th>
<th
class="w-[8%] min-w-[70px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('status')"
>
状态
<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="w-[6%] min-w-[60px] cursor-pointer px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('periodRequests')"
>
请求数
<i
v-if="apiKeysSortBy === 'periodRequests'"
: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="w-[6%] min-w-[60px] cursor-pointer px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('periodCost')"
>
费用
<i
v-if="apiKeysSortBy === 'periodCost'"
: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="w-[7%] min-w-[70px] cursor-pointer px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('periodTokens')"
>
Token数量
<i
v-if="apiKeysSortBy === 'periodTokens'"
: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="w-[9%] min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('lastUsedAt')"
>
最后使用
<i
v-if="apiKeysSortBy === 'lastUsedAt'"
: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="w-[10%] min-w-[90px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('createdAt')"
>
创建时间
<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="w-[10%] min-w-[90px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortApiKeys('expiresAt')"
>
过期时间
<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="w-[20%] min-w-[180px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
操作
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
<template v-for="key in paginatedApiKeys" :key="key.id">
<!-- API Key 主行 -->
<tr class="table-row">
<td v-if="shouldShowCheckboxes" class="px-3 py-2.5">
<div class="flex items-center">
<input
v-model="selectedApiKeys"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
type="checkbox"
:value="key.id"
@change="updateSelectAllState"
/>
</div>
</td>
<td class="px-3 py-2.5">
<div class="min-w-0">
<div
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
:title="key.name"
>
{{ key.name }}
</div>
<!-- 账户绑定信息 -->
<div class="mt-1.5 space-y-1">
<!-- Claude 绑定 -->
<div
v-if="key.claudeAccountId || key.claudeConsoleAccountId"
class="flex items-center gap-1 text-xs"
>
<span
class="inline-flex items-center rounded bg-indigo-100 px-1.5 py-0.5 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
>
<i class="fas fa-brain mr-1 text-[10px]" />
Claude
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getClaudeBindingInfo(key) }}
</span>
</div>
<!-- Gemini 绑定 -->
<div v-if="key.geminiAccountId" class="flex items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-yellow-100 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300"
>
<i class="fas fa-robot mr-1 text-[10px]" />
Gemini
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getGeminiBindingInfo(key) }}
</span>
</div>
<!-- OpenAI 绑定 -->
<div v-if="key.openaiAccountId" class="flex items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
>
<i class="fa-openai mr-1 text-[10px]" />
OpenAI
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getOpenAIBindingInfo(key) }}
</span>
</div>
<!-- Bedrock 绑定 -->
<div
v-if="key.bedrockAccountId"
class="flex items-center gap-1 text-xs"
>
<span
class="inline-flex items-center rounded bg-orange-100 px-1.5 py-0.5 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300"
>
<i class="fas fa-cloud mr-1 text-[10px]" />
Bedrock
</span>
<span class="truncate text-gray-600 dark:text-gray-400">
{{ getBedrockBindingInfo(key) }}
</span>
</div>
</div>
<!-- 显示所有者信息 -->
<div
v-if="isLdapEnabled && key.ownerDisplayName"
class="mt-1 text-xs text-red-600"
>
<i class="fas fa-user mr-1" />
{{ key.ownerDisplayName }}
</div>
</div>
</td>
<!-- 账号 -->
<td class="whitespace-nowrap px-3 py-2.5 text-sm">
<div class="text-gray-700 dark:text-gray-300">
<!-- Claude 账号 -->
<span v-if="key.claudeAccountId" class="inline-flex items-center">
<i class="fas fa-robot mr-1 text-xs text-blue-500" />
<span class="text-xs">{{
getClaudeAccountName(key.claudeAccountId)
}}</span>
</span>
<!-- Claude Console 账号 -->
<span
v-else-if="key.claudeConsoleAccountId"
class="inline-flex items-center"
>
<i class="fas fa-terminal mr-1 text-xs text-purple-500" />
<span class="text-xs">{{
getClaudeConsoleAccountName(key.claudeConsoleAccountId)
}}</span>
</span>
<!-- Gemini 账号 -->
<span v-else-if="key.geminiAccountId" class="inline-flex items-center">
<i class="fas fa-gem mr-1 text-xs text-green-500" />
<span class="text-xs">{{
getGeminiAccountName(key.geminiAccountId)
}}</span>
</span>
<!-- OpenAI 账号 -->
<span v-else-if="key.openaiAccountId" class="inline-flex items-center">
<i class="fas fa-brain mr-1 text-xs text-orange-500" />
<span class="text-xs">{{
getOpenAIAccountName(key.openaiAccountId)
}}</span>
</span>
<!-- Bedrock 账号 -->
<span v-else-if="key.bedrockAccountId" class="inline-flex items-center">
<i class="fas fa-layer-group mr-1 text-xs text-indigo-500" />
<span class="text-xs">{{
getBedrockAccountName(key.bedrockAccountId)
}}</span>
</span>
<!-- 共享池 -->
<span
v-else
class="inline-flex items-center text-gray-500 dark:text-gray-400"
>
<i class="fas fa-share-alt mr-1 text-xs" />
<span class="text-xs">共享池</span>
</span>
</div>
</td>
<td class="px-3 py-2.5">
<div class="flex flex-wrap gap-1">
<span
v-for="tag in key.tags || []"
:key="tag"
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
{{ tag }}
</span>
<span
v-if="!key.tags || key.tags.length === 0"
class="text-xs text-gray-400"
>无标签</span
>
</div>
</td>
<td class="whitespace-nowrap px-3 py-2.5">
<span
:class="[
'inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold',
key.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
]"
>
<div
:class="[
'mr-2 h-2 w-2 rounded-full',
key.isActive ? 'bg-green-500' : 'bg-red-500'
]"
/>
{{ key.isActive ? '活跃' : '禁用' }}
</span>
</td>
<!-- 请求数 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<div class="flex items-center justify-end gap-1">
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(getPeriodRequests(key)) }}
</span>
<span class="text-xs text-gray-500"></span>
</div>
</td>
<!-- 费用 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<div class="space-y-2">
<span class="font-medium text-blue-600 dark:text-blue-400">
${{ getPeriodCost(key).toFixed(4) }}
</span>
<!-- 每日费用限制进度条 -->
<div v-if="key.dailyCostLimit > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">每日费用</span>
<span class="text-gray-700 dark:text-gray-300">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{
key.dailyCostLimit.toFixed(2)
}}
</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="getDailyCostProgressColor(key)"
:style="{ width: getDailyCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- Opus 周费用限制进度条 -->
<div v-if="key.weeklyOpusCostLimit > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">Opus周费用</span>
<span class="text-gray-700 dark:text-gray-300">
${{ (key.weeklyOpusCost || 0).toFixed(2) }} / ${{
key.weeklyOpusCostLimit.toFixed(2)
}}
</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="getWeeklyOpusCostProgressColor(key)"
:style="{ width: getWeeklyOpusCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- 时间窗口限制进度条 -->
<WindowCountdown
v-if="key.rateLimitWindow > 0"
:cost-limit="key.rateLimitCost"
:current-cost="key.currentWindowCost"
:current-requests="key.currentWindowRequests"
:current-tokens="key.currentWindowTokens"
:rate-limit-window="key.rateLimitWindow"
:request-limit="key.rateLimitRequests"
:show-progress="true"
:show-tooltip="false"
:token-limit="key.tokenLimit"
:window-end-time="key.windowEndTime"
:window-remaining-seconds="key.windowRemainingSeconds"
:window-start-time="key.windowStartTime"
/>
</div>
</td>
<!-- Token数量 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<div class="flex items-center justify-end gap-1">
<span class="font-medium text-purple-600 dark:text-purple-400">
{{ formatTokenCount(getPeriodTokens(key)) }}
</span>
</div>
</td>
<!-- 最后使用 -->
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300"
>
{{ formatLastUsed(key.lastUsedAt) }}
</td>
<!-- 创建时间 -->
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-500 dark:text-gray-400"
>
{{ new Date(key.createdAt).toLocaleDateString() }}
</td>
<td class="whitespace-nowrap px-3 py-2.5 text-sm">
<div class="inline-flex items-center gap-1.5">
<!-- 未激活状态 -->
<span
v-if="key.expirationMode === 'activation' && !key.isActivated"
class="inline-flex items-center text-blue-600 dark:text-blue-400"
>
<i class="fas fa-pause-circle mr-1" />
未激活 ({{ key.activationDays || 30 }})
</span>
<!-- 已设置过期时间 -->
<span v-else-if="key.expiresAt">
<span
v-if="isApiKeyExpired(key.expiresAt)"
class="inline-flex items-center text-red-600"
>
<i class="fas fa-exclamation-circle mr-1" />
已过期
</span>
<span
v-else-if="isApiKeyExpiringSoon(key.expiresAt)"
class="inline-flex items-center text-orange-600"
>
<i class="fas fa-clock mr-1" />
{{ formatExpireDate(key.expiresAt) }}
</span>
<span v-else class="text-gray-600 dark:text-gray-400">
{{ formatExpireDate(key.expiresAt) }}
</span>
</span>
<!-- 永不过期 -->
<span
v-else
class="inline-flex items-center text-gray-400 dark:text-gray-500"
>
<i class="fas fa-infinity mr-1" />
永不过期
</span>
<button
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-gray-300 transition-all duration-200 hover:bg-blue-50 hover:text-blue-500 dark:hover:bg-blue-900/20"
title="编辑过期时间"
@click.stop="startEditExpiry(key)"
>
<svg
class="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
></path>
</svg>
</button>
</div>
</td>
<td class="whitespace-nowrap px-3 py-2.5 text-sm">
<div class="flex gap-1">
<button
class="rounded px-2 py-1 text-xs font-medium text-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900 dark:hover:bg-purple-900/20"
title="查看详细统计"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line" />
<span class="ml-1 hidden xl:inline">详情</span>
</button>
<button
v-if="key && key.id"
class="rounded px-2 py-1 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 hover:text-indigo-900 dark:hover:bg-indigo-900/20"
title="模型使用分布"
@click="toggleApiKeyModelStats(key.id)"
>
<i
:class="[
'fas',
expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down'
]"
/>
<span class="ml-1 hidden xl:inline">模型</span>
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-900 dark:hover:bg-blue-900/20"
title="编辑"
@click="openEditApiKeyModal(key)"
>
<i class="fas fa-edit" />
<span class="ml-1 hidden xl:inline">编辑</span>
</button>
<button
v-if="
key.expiresAt &&
(isApiKeyExpired(key.expiresAt) ||
isApiKeyExpiringSoon(key.expiresAt))
"
class="rounded px-2 py-1 text-xs font-medium text-green-600 transition-colors hover:bg-green-50 hover:text-green-900 dark:hover:bg-green-900/20"
title="续期"
@click="openRenewApiKeyModal(key)"
>
<i class="fas fa-clock" />
<span class="ml-1 hidden xl:inline">续期</span>
</button>
<button
:class="[
key.isActive
? 'text-orange-600 hover:bg-orange-50 hover:text-orange-900 dark:hover:bg-orange-900/20'
: 'text-green-600 hover:bg-green-50 hover:text-green-900 dark:hover:bg-green-900/20',
'rounded px-2 py-1 text-xs font-medium transition-colors'
]"
:title="key.isActive ? '禁用' : '激活'"
@click="toggleApiKeyStatus(key)"
>
<i :class="['fas', key.isActive ? 'fa-ban' : 'fa-check-circle']" />
<span class="ml-1 hidden xl:inline">{{
key.isActive ? '禁用' : '激活'
}}</span>
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900 dark:hover:bg-red-900/20"
title="删除"
@click="deleteApiKey(key.id)"
>
<i class="fas fa-trash" />
<span class="ml-1 hidden xl:inline">删除</span>
</button>
</div>
</td>
</tr>
<!-- 模型统计展开区域 -->
<tr v-if="key && key.id && expandedApiKeys[key.id]">
<td class="bg-gray-50 px-3 py-3 dark:bg-gray-700" colspan="12">
<div v-if="!apiKeyModelStats[key.id]" class="py-4 text-center">
<div class="loading-spinner mx-auto" />
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
加载模型统计...
</p>
</div>
<div class="space-y-4">
<!-- 通用的标题和时间筛选器,无论是否有数据都显示 -->
<div class="mb-4 flex items-center justify-between">
<h5
class="flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-chart-pie mr-2 text-indigo-500" />
模型使用分布
</h5>
<div class="flex items-center gap-2">
<span
v-if="
apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0
"
class="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
{{ apiKeyModelStats[key.id].length }} 个模型
</span>
<!-- API Keys日期筛选器 -->
<div class="flex items-center gap-1">
<!-- 快捷日期选择 -->
<div class="flex gap-1 rounded bg-gray-100 p-1 dark:bg-gray-700">
<button
v-for="option in getApiKeyDateFilter(key.id).presetOptions"
:key="option.value"
:class="[
'rounded px-2 py-1 text-xs font-medium transition-colors',
getApiKeyDateFilter(key.id).preset === option.value &&
getApiKeyDateFilter(key.id).type === 'preset'
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
@click="setApiKeyDateFilterPreset(option.value, key.id)"
>
{{ option.label }}
</button>
</div>
<!-- Element Plus 日期范围选择器 -->
<el-date-picker
class="api-key-date-picker"
:clearable="true"
:default-time="defaultTime"
:disabled-date="disabledDate"
end-placeholder="结束日期"
format="YYYY-MM-DD HH:mm:ss"
:model-value="getApiKeyDateFilter(key.id).customRange"
range-separator="至"
size="small"
start-placeholder="开始日期"
style="width: 280px"
type="datetimerange"
:unlink-panels="false"
value-format="YYYY-MM-DD HH:mm:ss"
@update:model-value="
(value) => onApiKeyCustomDateRangeChange(key.id, value)
"
/>
</div>
</div>
</div>
<!-- 数据展示区域 -->
<div
v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length === 0"
class="py-8 text-center"
>
<div class="mb-3 flex items-center justify-center gap-2">
<i class="fas fa-chart-line text-lg text-gray-400" />
<p class="text-sm text-gray-500 dark:text-gray-400">
暂无模型使用数据
</p>
<button
class="ml-2 flex items-center gap-1 text-sm text-blue-500 transition-colors hover:text-blue-700"
title="重置筛选条件并刷新"
@click="resetApiKeyDateFilter(key.id)"
>
<i class="fas fa-sync-alt text-xs" />
<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 gap-4 md:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="stat in apiKeyModelStats[key.id]"
:key="stat.model"
class="rounded-xl border border-gray-200 bg-gradient-to-br from-white to-gray-50 p-4 transition-all duration-200 hover:border-indigo-300 hover:shadow-lg dark:border-gray-600 dark:from-gray-800 dark:to-gray-700 dark:hover:border-indigo-500"
>
<div class="mb-3 flex items-start justify-between">
<div class="flex-1">
<span
class="mb-1 block text-sm font-semibold text-gray-800 dark:text-gray-200"
>{{ stat.model }}</span
>
<span
class="rounded-full bg-blue-50 px-2 py-1 text-xs text-gray-500 dark:bg-blue-900/30 dark:text-gray-400"
>{{ stat.requests }} 次请求</span
>
</div>
</div>
<div class="mb-3 space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="flex items-center text-gray-600 dark:text-gray-400">
<i class="fas fa-coins mr-1 text-xs text-yellow-500" />
总Token:
</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">{{
formatTokenCount(stat.allTokens)
}}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="flex items-center text-gray-600 dark:text-gray-400">
<i class="fas fa-dollar-sign mr-1 text-xs text-green-500" />
费用:
</span>
<span class="font-semibold text-green-600">{{
calculateModelCost(stat)
}}</span>
</div>
<div
class="mt-2 border-t border-gray-100 pt-2 dark:border-gray-600"
>
<div
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
>
<span class="flex items-center">
<i class="fas fa-arrow-down mr-1 text-green-500" />
输入:
</span>
<span class="font-medium">{{
formatTokenCount(stat.inputTokens)
}}</span>
</div>
<div
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
>
<span class="flex items-center">
<i class="fas fa-arrow-up mr-1 text-blue-500" />
输出:
</span>
<span class="font-medium">{{
formatTokenCount(stat.outputTokens)
}}</span>
</div>
<div
v-if="stat.cacheCreateTokens > 0"
class="flex items-center justify-between text-xs text-purple-600"
>
<span class="flex items-center">
<i class="fas fa-save mr-1" />
缓存创建:
</span>
<span class="font-medium">{{
formatTokenCount(stat.cacheCreateTokens)
}}</span>
</div>
<div
v-if="stat.cacheReadTokens > 0"
class="flex items-center justify-between text-xs text-purple-600"
>
<span class="flex items-center">
<i class="fas fa-download mr-1" />
缓存读取:
</span>
<span class="font-medium">{{
formatTokenCount(stat.cacheReadTokens)
}}</span>
</div>
</div>
</div>
<!-- 进度条 -->
<div
class="mt-3 h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700"
>
<div
class="h-2 rounded-full bg-gradient-to-r from-indigo-500 to-purple-600 transition-all duration-500"
:style="{
width:
calculateApiKeyModelPercentage(
stat.allTokens,
apiKeyModelStats[key.id]
) + '%'
}"
/>
</div>
<div class="mt-1 text-right">
<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 rounded-lg border border-indigo-100 bg-gradient-to-r from-indigo-50 to-purple-50 p-3 dark:border-indigo-700 dark:from-indigo-900/20 dark:to-purple-900/20"
>
<div class="flex items-center justify-between text-sm">
<span
class="flex items-center font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-calculator mr-2 text-indigo-500" />
总计统计
</span>
<div class="flex gap-4 text-xs">
<span class="text-gray-600 dark:text-gray-400">
总请求:
<span class="font-semibold text-gray-800 dark:text-gray-200">{{
apiKeyModelStats[key.id].reduce(
(sum, stat) => sum + stat.requests,
0
)
}}</span>
</span>
<span class="text-gray-600 dark:text-gray-400">
总Token:
<span class="font-semibold text-gray-800 dark:text-gray-200">{{
formatTokenCount(
apiKeyModelStats[key.id].reduce(
(sum, stat) => sum + stat.allTokens,
0
)
)
}}</span>
</span>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- 移动端卡片视图 -->
<div v-if="!apiKeysLoading && sortedApiKeys.length > 0" class="space-y-3 md:hidden">
<div
v-for="key in paginatedApiKeys"
:key="key.id"
class="card p-4 transition-shadow hover:shadow-lg"
>
<!-- 卡片头部 -->
<div class="mb-3 flex items-start justify-between">
<div class="flex items-center gap-3">
<input
v-if="shouldShowCheckboxes"
v-model="selectedApiKeys"
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
type="checkbox"
:value="key.id"
@change="updateSelectAllState"
/>
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"
>
<i class="fas fa-key text-sm text-white" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ key.name }}
</h4>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ key.id }}
</p>
</div>
</div>
<span
:class="[
'inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold',
key.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
]"
>
<div
:class="[
'mr-1.5 h-1.5 w-1.5 rounded-full',
key.isActive ? 'bg-green-500' : 'bg-red-500'
]"
/>
{{ key.isActive ? '活跃' : '已停用' }}
</span>
</div>
<!-- 账户绑定信息 -->
<div class="mb-3 space-y-1.5">
<!-- Claude 绑定 -->
<div
v-if="key.claudeAccountId || key.claudeConsoleAccountId"
class="flex flex-wrap items-center gap-1 text-xs"
>
<span
class="inline-flex items-center rounded bg-indigo-100 px-2 py-0.5 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
>
<i class="fas fa-brain mr-1" />
Claude
</span>
<span class="text-gray-600 dark:text-gray-400">
{{ getClaudeBindingInfo(key) }}
</span>
</div>
<!-- Gemini 绑定 -->
<div v-if="key.geminiAccountId" class="flex flex-wrap items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-yellow-100 px-2 py-0.5 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300"
>
<i class="fas fa-robot mr-1" />
Gemini
</span>
<span class="text-gray-600 dark:text-gray-400">
{{ getGeminiBindingInfo(key) }}
</span>
</div>
<!-- OpenAI 绑定 -->
<div v-if="key.openaiAccountId" class="flex flex-wrap items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
>
<i class="fa-openai mr-1" />
OpenAI
</span>
<span class="text-gray-600 dark:text-gray-400">
{{ getOpenAIBindingInfo(key) }}
</span>
</div>
<!-- Bedrock 绑定 -->
<div v-if="key.bedrockAccountId" class="flex flex-wrap items-center gap-1 text-xs">
<span
class="inline-flex items-center rounded bg-orange-100 px-2 py-0.5 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300"
>
<i class="fas fa-cloud mr-1" />
Bedrock
</span>
<span class="text-gray-600 dark:text-gray-400">
{{ getBedrockBindingInfo(key) }}
</span>
</div>
<!-- 无绑定时显示共享池 -->
<div
v-if="
!key.claudeAccountId &&
!key.claudeConsoleAccountId &&
!key.geminiAccountId &&
!key.openaiAccountId &&
!key.bedrockAccountId
"
class="text-xs text-gray-500 dark:text-gray-400"
>
<i class="fas fa-share-alt mr-1" />
使用共享池
</div>
<!-- 显示所有者信息 -->
<div v-if="isLdapEnabled && key.ownerDisplayName" class="text-xs text-red-600">
<i class="fas fa-user mr-1" />
{{ key.ownerDisplayName }}
</div>
</div>
<!-- 统计信息 -->
<div class="mb-3 space-y-2">
<!-- 今日使用 -->
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-700">
<div class="mb-2 flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">{{
globalDateFilter.type === 'custom' ? '累计统计' : '今日使用'
}}</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 dark:text-gray-100">
{{ formatNumber(key.usage?.daily?.requests || 0) }} 次
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">请求</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 dark:text-gray-400">费用</p>
</div>
</div>
<div class="mt-2 flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">最后使用</span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
formatLastUsed(key.lastUsedAt)
}}</span>
</div>
</div>
<!-- 限制进度 -->
<div v-if="key.dailyCostLimit > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">每日费用限额</span>
<span class="text-gray-700 dark:text-gray-300">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full transition-all duration-300"
:class="getDailyCostProgressColor(key)"
:style="{ width: getDailyCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- 移动端时间窗口限制 -->
<WindowCountdown
v-if="
key.rateLimitWindow > 0 &&
(key.rateLimitRequests > 0 || key.tokenLimit > 0 || key.rateLimitCost > 0)
"
:cost-limit="key.rateLimitCost"
:current-cost="key.currentWindowCost"
:current-requests="key.currentWindowRequests"
:current-tokens="key.currentWindowTokens"
:rate-limit-window="key.rateLimitWindow"
:request-limit="key.rateLimitRequests"
:show-progress="true"
:show-tooltip="false"
:token-limit="key.tokenLimit"
:window-end-time="key.windowEndTime"
:window-remaining-seconds="key.windowRemainingSeconds"
:window-start-time="key.windowStartTime"
/>
</div>
<!-- 时间信息 -->
<div class="mb-3 text-xs text-gray-500 dark:text-gray-400">
<div class="mb-1 flex justify-between">
<span>创建时间</span>
<span>{{ formatDate(key.createdAt) }}</span>
</div>
<div class="flex items-center justify-between">
<span>过期时间</span>
<div class="flex items-center gap-1">
<span
:class="
isApiKeyExpiringSoon(key.expiresAt) ? 'font-semibold text-orange-600' : ''
"
>
{{ key.expiresAt ? formatDate(key.expiresAt) : '永不过期' }}
</span>
<button
class="inline-flex h-5 w-5 items-center justify-center rounded text-gray-300 transition-all duration-200 hover:bg-blue-50 hover:text-blue-500 dark:hover:bg-blue-900/20"
title="编辑过期时间"
@click.stop="startEditExpiry(key)"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
></path>
</svg>
</button>
</div>
</div>
</div>
<!-- 标签 -->
<div v-if="key.tags && key.tags.length > 0" class="mb-3 flex flex-wrap gap-1">
<span
v-for="tag in key.tags"
:key="tag"
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
{{ tag }}
</span>
</div>
<!-- 操作按钮 -->
<div class="mt-3 flex gap-2 border-t border-gray-100 pt-3 dark:border-gray-600">
<button
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-600 transition-colors hover:bg-blue-100 dark:bg-blue-900/30 dark:hover:bg-blue-900/50"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line" />
查看详情
</button>
<button
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="openEditApiKeyModal(key)"
>
<i class="fas fa-edit mr-1" />
编辑
</button>
<button
v-if="
key.expiresAt &&
(isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))
"
class="flex-1 rounded-lg bg-orange-50 px-3 py-2 text-xs text-orange-600 transition-colors hover:bg-orange-100 dark:bg-orange-900/30 dark:hover:bg-orange-900/50"
@click="openRenewApiKeyModal(key)"
>
<i class="fas fa-clock mr-1" />
续期
</button>
<button
:class="[
key.isActive
? 'bg-orange-50 text-orange-600 hover:bg-orange-100 dark:bg-orange-900/30 dark:hover:bg-orange-900/50'
: 'bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/30 dark:hover:bg-green-900/50',
'rounded-lg px-3 py-2 text-xs transition-colors'
]"
@click="toggleApiKeyStatus(key)"
>
<i :class="['fas', key.isActive ? 'fa-ban' : 'fa-check-circle', 'mr-1']" />
{{ key.isActive ? '禁用' : '激活' }}
</button>
<button
class="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:hover:bg-red-900/50"
@click="deleteApiKey(key.id)"
>
<i class="fas fa-trash" />
</button>
</div>
</div>
</div>
<!-- 分页组件 -->
<div
v-if="sortedApiKeys.length > 0"
class="mt-4 flex flex-col items-center justify-between gap-4 sm:mt-6 sm:flex-row"
>
<div class="flex w-full flex-col items-center gap-3 sm:w-auto sm:flex-row">
<span class="text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
共 {{ sortedApiKeys.length }} 条记录
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-600 dark:text-gray-400 sm:text-sm">每页显示</span>
<select
v-model="pageSize"
class="rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 transition-colors hover:border-gray-300 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:text-sm"
@change="currentPage = 1"
>
<option v-for="size in pageSizeOptions" :key="size" :value="size">
{{ size }}
</option>
</select>
<span class="text-xs text-gray-600 dark:text-gray-400 sm:text-sm">条</span>
</div>
</div>
<div class="flex items-center gap-2">
<!-- 上一页 -->
<button
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 sm:py-1 sm:text-sm"
:disabled="currentPage === 1"
@click="currentPage--"
>
<i class="fas fa-chevron-left" />
</button>
<!-- 页码 -->
<div class="flex items-center gap-1">
<!-- 第一页 -->
<button
v-if="currentPage > 3"
class="hidden rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 sm:block"
@click="currentPage = 1"
>
1
</button>
<span
v-if="currentPage > 4"
class="hidden px-2 text-gray-500 dark:text-gray-400 sm:inline"
>...</span
>
<!-- 中间页码 -->
<button
v-for="page in pageNumbers"
:key="page"
:class="[
'rounded-md px-2 py-1 text-xs font-medium sm:px-3 sm:text-sm',
page === currentPage
? 'bg-blue-600 text-white'
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
]"
@click="currentPage = page"
>
{{ page }}
</button>
<!-- 最后一页 -->
<span
v-if="currentPage < totalPages - 3"
class="hidden px-2 text-gray-500 dark:text-gray-400 sm:inline"
>...</span
>
<button
v-if="totalPages > 1 && currentPage < totalPages - 2"
class="hidden rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 sm:block"
@click="currentPage = totalPages"
>
{{ totalPages }}
</button>
</div>
<!-- 下一页 -->
<button
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 sm:py-1 sm:text-sm"
:disabled="currentPage === totalPages || totalPages === 0"
@click="currentPage++"
>
<i class="fas fa-chevron-right" />
</button>
</div>
</div>
</div>
<!-- 已删除 API Keys Tab Panel -->
<div v-else-if="activeTab === 'deleted'" class="tab-panel">
<div v-if="deletedApiKeysLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">正在加载已删除的 API Keys...</p>
</div>
<div v-else-if="deletedApiKeys.length === 0" class="py-12 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700"
>
<i class="fas fa-trash text-xl text-gray-400" />
</div>
<p class="text-lg text-gray-500 dark:text-gray-400">暂无已删除的 API Keys</p>
<p class="mt-2 text-sm text-gray-400">已删除的 API Keys 会出现在这里</p>
</div>
<!-- 已删除的 API Keys 表格 -->
<div v-else>
<!-- 工具栏 -->
<div class="mb-4 flex justify-end">
<button
v-if="deletedApiKeys.length > 0"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700"
@click="clearAllDeletedApiKeys"
>
<i class="fas fa-trash-alt mr-2" />
清空所有已删除 ({{ deletedApiKeys.length }})
</button>
</div>
<div class="table-wrapper">
<div class="table-container">
<table class="w-full table-fixed">
<thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
<tr>
<th
class="w-[18%] min-w-[140px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
名称
</th>
<th
class="w-[10%] min-w-[90px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
账号
</th>
<th
v-if="isLdapEnabled"
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
创建者
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
创建时间
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
删除者
</th>
<th
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
删除时间
</th>
<th
class="w-[6%] min-w-[60px] px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
请求数
</th>
<th
class="w-[6%] min-w-[60px] px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
费用
</th>
<th
class="w-[7%] min-w-[70px] px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
Token数量
</th>
<th
class="w-[9%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
最后使用
</th>
<th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
操作
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
<tr v-for="key in deletedApiKeys" :key="key.id" class="table-row">
<td class="px-3 py-2.5">
<div class="flex items-center">
<div
class="mr-2 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-600"
>
<i class="fas fa-trash text-[10px] text-white" />
</div>
<div class="min-w-0">
<div
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
:title="key.name"
>
{{ key.name }}
</div>
</div>
</div>
</td>
<!-- 账号 -->
<td class="whitespace-nowrap px-3 py-2.5 text-sm">
<div class="text-gray-700 dark:text-gray-300">
<!-- Claude 账号 -->
<span v-if="key.claudeAccountId" class="inline-flex items-center">
<i class="fas fa-robot mr-1 text-xs text-blue-500" />
<span class="text-xs">{{
getClaudeAccountName(key.claudeAccountId)
}}</span>
</span>
<!-- Claude Console 账号 -->
<span
v-else-if="key.claudeConsoleAccountId"
class="inline-flex items-center"
>
<i class="fas fa-terminal mr-1 text-xs text-purple-500" />
<span class="text-xs">{{
getClaudeConsoleAccountName(key.claudeConsoleAccountId)
}}</span>
</span>
<!-- Gemini 账号 -->
<span v-else-if="key.geminiAccountId" class="inline-flex items-center">
<i class="fas fa-gem mr-1 text-xs text-green-500" />
<span class="text-xs">{{
getGeminiAccountName(key.geminiAccountId)
}}</span>
</span>
<!-- OpenAI 账号 -->
<span v-else-if="key.openaiAccountId" class="inline-flex items-center">
<i class="fas fa-brain mr-1 text-xs text-orange-500" />
<span class="text-xs">{{
getOpenAIAccountName(key.openaiAccountId)
}}</span>
</span>
<!-- Bedrock 账号 -->
<span v-else-if="key.bedrockAccountId" class="inline-flex items-center">
<i class="fas fa-layer-group mr-1 text-xs text-indigo-500" />
<span class="text-xs">{{
getBedrockAccountName(key.bedrockAccountId)
}}</span>
</span>
<!-- 共享池 -->
<span
v-else
class="inline-flex items-center text-gray-500 dark:text-gray-400"
>
<i class="fas fa-share-alt mr-1 text-xs" />
<span class="text-xs">共享池</span>
</span>
</div>
</td>
<td v-if="isLdapEnabled" class="px-3 py-2.5">
<div class="text-sm">
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1" />
管理员
</span>
<span v-else-if="key.userUsername" class="text-green-600">
<i class="fas fa-user mr-1" />
{{ key.userUsername }}
</span>
<span v-else class="text-gray-500 dark:text-gray-400">
<i class="fas fa-question-circle mr-1" />
未知
</span>
</div>
</td>
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-500 dark:text-gray-400"
>
{{ formatDate(key.createdAt) }}
</td>
<td class="px-3 py-2.5">
<div class="text-sm">
<span v-if="key.deletedByType === 'admin'" class="text-blue-600">
<i class="fas fa-user-shield mr-1" />
{{ key.deletedBy }}
</span>
<span v-else-if="key.deletedByType === 'user'" class="text-green-600">
<i class="fas fa-user mr-1" />
{{ key.deletedBy }}
</span>
<span v-else class="text-gray-500 dark:text-gray-400">
<i class="fas fa-cog mr-1" />
{{ key.deletedBy }}
</span>
</div>
</td>
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-500 dark:text-gray-400"
>
{{ formatDate(key.deletedAt) }}
</td>
<!-- 请求数 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<div class="flex items-center justify-end gap-1">
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(key.usage?.total?.requests || 0) }}
</span>
<span class="text-xs text-gray-500">次</span>
</div>
</td>
<!-- 费用 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<span class="font-medium text-green-600 dark:text-green-400">
${{ (key.usage?.total?.cost || 0).toFixed(4) }}
</span>
</td>
<!-- Token数量 -->
<td class="whitespace-nowrap px-3 py-2.5 text-right text-sm">
<span class="font-medium text-purple-600 dark:text-purple-400">
{{ formatTokenCount(key.usage?.total?.tokens || 0) }}
</span>
</td>
<td
class="whitespace-nowrap px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300"
>
<span v-if="key.lastUsedAt">
{{ formatLastUsed(key.lastUsedAt) }}
</span>
<span v-else class="text-gray-400">从未使用</span>
</td>
<td class="px-3 py-2.5">
<div class="flex items-center gap-2">
<button
v-if="key.canRestore"
class="rounded-lg bg-green-50 px-3 py-1.5 text-xs font-medium text-green-600 transition-colors hover:bg-green-100 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50"
title="恢复 API Key"
@click="restoreApiKey(key.id)"
>
<i class="fas fa-undo mr-1" />
恢复
</button>
<button
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
title="彻底删除 API Key"
@click="permanentDeleteApiKey(key.id)"
>
<i class="fas fa-times mr-1" />
彻底删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 模态框组件 -->
<CreateApiKeyModal
v-if="showCreateApiKeyModal"
:accounts="accounts"
@batch-success="handleBatchCreateSuccess"
@close="showCreateApiKeyModal = false"
@success="handleCreateSuccess"
/>
<EditApiKeyModal
v-if="showEditApiKeyModal"
:accounts="accounts"
:api-key="editingApiKey"
@close="showEditApiKeyModal = false"
@success="handleEditSuccess"
/>
<RenewApiKeyModal
v-if="showRenewApiKeyModal"
:api-key="renewingApiKey"
@close="showRenewApiKeyModal = false"
@success="handleRenewSuccess"
/>
<NewApiKeyModal
v-if="showNewApiKeyModal"
:api-key="newApiKeyData"
@close="showNewApiKeyModal = false"
/>
<BatchApiKeyModal
v-if="showBatchApiKeyModal"
:api-keys="batchApiKeyData"
@close="showBatchApiKeyModal = false"
/>
<BatchEditApiKeyModal
v-if="showBatchEditModal"
:accounts="accounts"
:selected-keys="selectedApiKeys"
@close="showBatchEditModal = false"
@success="handleBatchEditSuccess"
/>
<!-- 过期时间编辑弹窗 -->
<ExpiryEditModal
ref="expiryEditModalRef"
:api-key="editingExpiryKey || { id: null, expiresAt: null, name: '' }"
:show="!!editingExpiryKey"
@close="closeExpiryEdit"
@save="handleSaveExpiry"
/>
<!-- 使用详情弹窗 -->
<UsageDetailModal
:api-key="selectedApiKeyForDetail || {}"
:show="showUsageDetailModal"
@close="showUsageDetailModal = false"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import { useClientsStore } from '@/stores/clients'
import { useAuthStore } from '@/stores/auth'
import * as XLSX from 'xlsx'
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'
import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
import BatchEditApiKeyModal from '@/components/apikeys/BatchEditApiKeyModal.vue'
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue'
// 响应式数据
const clientsStore = useClientsStore()
const authStore = useAuthStore()
const apiKeys = ref([])
// 获取 LDAP 启用状态
const isLdapEnabled = computed(() => authStore.oemSettings?.ldapEnabled || false)
// 多选相关状态
const selectedApiKeys = ref([])
const selectAllChecked = ref(false)
const isIndeterminate = ref(false)
const showCheckboxes = ref(false)
const apiKeysLoading = ref(false)
const apiKeyStatsTimeRange = ref('today')
// 全局日期筛选器
const globalDateFilter = reactive({
type: 'preset',
preset: '7days',
customStart: '',
customEnd: '',
customRange: null
})
// 是否应该显示多选框
const shouldShowCheckboxes = computed(() => {
return showCheckboxes.value
})
// 切换选择模式
const toggleSelectionMode = () => {
showCheckboxes.value = !showCheckboxes.value
// 关闭选择模式时清空已选项
if (!showCheckboxes.value) {
selectedApiKeys.value = []
selectAllChecked.value = false
isIndeterminate.value = false
}
}
// 时间范围下拉选项
const timeRangeDropdownOptions = computed(() => [
{ value: '7days', label: '最近7天', icon: 'fa-calendar-week' },
{ value: '30days', label: '最近30天', icon: 'fa-calendar-alt' },
{ value: 'all', label: '全部时间', icon: 'fa-infinity' },
{ value: 'custom', label: '自定义范围', icon: 'fa-calendar-check' }
])
// Tab management
const activeTab = ref('active')
const deletedApiKeys = ref([])
const deletedApiKeysLoading = ref(false)
const apiKeysSortBy = ref('periodCost')
const apiKeysSortOrder = ref('desc')
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: [],
openai: [],
bedrock: [],
claudeGroups: [],
geminiGroups: [],
openaiGroups: []
})
const editingExpiryKey = ref(null)
const expiryEditModalRef = ref(null)
const showUsageDetailModal = ref(false)
const selectedApiKeyForDetail = ref(null)
// 标签相关
const selectedTagFilter = ref('')
const availableTags = ref([])
// 搜索相关
const searchKeyword = ref('')
const tagOptions = computed(() => {
const options = [{ value: '', label: '所有标签', icon: 'fa-asterisk' }]
availableTags.value.forEach((tag) => {
options.push({ value: tag, label: tag, icon: 'fa-tag' })
})
return options
})
const selectedTagCount = computed(() => {
if (!selectedTagFilter.value) return 0
return apiKeys.value.filter((key) => key.tags && key.tags.includes(selectedTagFilter.value))
.length
})
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizeOptions = [10, 20, 50, 100]
// 模态框状态
const showCreateApiKeyModal = ref(false)
const showEditApiKeyModal = ref(false)
const showRenewApiKeyModal = ref(false)
const showNewApiKeyModal = ref(false)
const showBatchApiKeyModal = ref(false)
const showBatchEditModal = ref(false)
const editingApiKey = ref(null)
const renewingApiKey = ref(null)
const newApiKeyData = ref(null)
const batchApiKeyData = ref([])
// 计算排序后的API Keys
const sortedApiKeys = computed(() => {
// 先进行标签筛选
let filteredKeys = apiKeys.value
if (selectedTagFilter.value) {
filteredKeys = apiKeys.value.filter(
(key) => key.tags && key.tags.includes(selectedTagFilter.value)
)
}
// 然后进行名称搜索搜索API Key名称和所有者名称
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase().trim()
filteredKeys = filteredKeys.filter((key) => {
// 搜索API Key名称
const nameMatch = key.name && key.name.toLowerCase().includes(keyword)
// 如果启用了 LDAP搜索所有者名称
if (isLdapEnabled.value) {
const ownerMatch =
key.ownerDisplayName && key.ownerDisplayName.toLowerCase().includes(keyword)
// 如果API Key名称或所有者名称匹配则包含该条目
return nameMatch || ownerMatch
}
// 未启用 LDAP 时只搜索名称
return nameMatch
})
}
// 如果没有排序字段,返回筛选后的结果
if (!apiKeysSortBy.value) return filteredKeys
// 排序
const sorted = [...filteredKeys].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 === 'periodRequests') {
aVal = getPeriodRequests(a)
bVal = getPeriodRequests(b)
} else if (apiKeysSortBy.value === 'periodCost') {
aVal = calculatePeriodCost(a)
bVal = calculatePeriodCost(b)
} else if (apiKeysSortBy.value === 'periodTokens') {
aVal = getPeriodTokens(a)
bVal = getPeriodTokens(b)
} else if (apiKeysSortBy.value === 'dailyCost') {
aVal = a.dailyCost || 0
bVal = b.dailyCost || 0
} else if (apiKeysSortBy.value === 'totalCost') {
aVal = a.totalCost || 0
bVal = b.totalCost || 0
} else if (
apiKeysSortBy.value === 'createdAt' ||
apiKeysSortBy.value === 'expiresAt' ||
apiKeysSortBy.value === 'lastUsedAt'
) {
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 totalPages = computed(() => {
const total = sortedApiKeys.value.length
return Math.ceil(total / pageSize.value) || 0
})
// 计算显示的页码数组
const pageNumbers = computed(() => {
const pages = []
const current = currentPage.value
const total = totalPages.value
if (total <= 7) {
// 如果总页数小于等于7显示所有页码
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// 如果总页数大于7显示部分页码
let start = Math.max(1, current - 2)
let end = Math.min(total, current + 2)
// 调整起始和结束页码
if (current <= 3) {
end = 5
} else if (current >= total - 2) {
start = total - 4
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
}
return pages
})
// 获取分页后的数据
const paginatedApiKeys = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return sortedApiKeys.value.slice(start, end)
})
// 加载账户列表
const loadAccounts = async () => {
try {
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/bedrock-accounts'),
apiClient.get('/admin/account-groups')
])
// 合并Claude OAuth账户和Claude Console账户
const claudeAccounts = []
if (claudeData.success) {
claudeData.data?.forEach((account) => {
claudeAccounts.push({
...account,
platform: 'claude-oauth',
isDedicated: account.accountType === 'dedicated'
})
})
}
if (claudeConsoleData.success) {
claudeConsoleData.data?.forEach((account) => {
claudeAccounts.push({
...account,
platform: 'claude-console',
isDedicated: account.accountType === 'dedicated'
})
})
}
accounts.value.claude = claudeAccounts
if (geminiData.success) {
accounts.value.gemini = (geminiData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
}
if (openaiData.success) {
accounts.value.openai = (openaiData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
}
if (bedrockData.success) {
accounts.value.bedrock = (bedrockData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
}
if (groupsData.success) {
// 处理分组数据
const allGroups = groupsData.data || []
accounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
accounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
accounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
}
} catch (error) {
console.error('加载账户列表失败:', error)
}
}
// 加载API Keys
const loadApiKeys = async () => {
apiKeysLoading.value = true
try {
// 构建请求参数
let params = {}
if (
globalDateFilter.type === 'custom' &&
globalDateFilter.customStart &&
globalDateFilter.customEnd
) {
params.startDate = globalDateFilter.customStart
params.endDate = globalDateFilter.customEnd
params.timeRange = 'custom'
} else if (globalDateFilter.preset === 'all') {
params.timeRange = 'all'
} else {
params.timeRange = globalDateFilter.preset
}
const queryString = new URLSearchParams(params).toString()
const data = await apiClient.get(`/admin/api-keys?${queryString}`)
if (data.success) {
apiKeys.value = data.data || []
// 更新可用标签列表
const tagsSet = new Set()
apiKeys.value.forEach((key) => {
if (key.tags && Array.isArray(key.tags)) {
key.tags.forEach((tag) => tagsSet.add(tag))
}
})
availableTags.value = Array.from(tagsSet).sort()
}
} catch (error) {
showToast('加载 API Keys 失败', 'error')
} finally {
apiKeysLoading.value = false
}
}
// 加载已删除的API Keys
const loadDeletedApiKeys = async () => {
activeTab.value = 'deleted'
deletedApiKeysLoading.value = true
try {
const data = await apiClient.get('/admin/api-keys/deleted')
if (data.success) {
deletedApiKeys.value = data.apiKeys || []
}
} catch (error) {
showToast('加载已删除的 API Keys 失败', 'error')
} finally {
deletedApiKeysLoading.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')
}
// 格式化Token数量
const formatTokenCount = (count) => {
if (!count && count !== 0) return '0'
if (count >= 1000000) {
return (count / 1000000).toFixed(1) + 'M'
} else if (count >= 1000) {
return (count / 1000).toFixed(1) + 'K'
}
return count.toString()
}
// 获取绑定账户名称
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}`
}
// 从OpenAI分组中查找
const openaiGroup = accounts.value.openaiGroups.find((g) => g.id === groupId)
if (openaiGroup) {
return `分组-${openaiGroup.name}`
}
// 如果找不到分组返回分组ID的前8位
return `分组-${groupId.substring(0, 8)}`
}
// 从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}`
}
// 从OpenAI账户列表中查找
const openaiAccount = accounts.value.openai.find((acc) => acc.id === accountId)
if (openaiAccount) {
return `${openaiAccount.name}`
}
// 从Bedrock账户列表中查找
const bedrockAccount = accounts.value.bedrock.find((acc) => acc.id === accountId)
if (bedrockAccount) {
return `${bedrockAccount.name}`
}
// 如果找不到返回账户ID的前8位
return `${accountId.substring(0, 8)}`
}
// 获取Claude绑定信息
const getClaudeBindingInfo = (key) => {
if (key.claudeAccountId) {
const info = getBoundAccountName(key.claudeAccountId)
if (key.claudeAccountId.startsWith('group:')) {
return info
}
// 检查账户是否存在
const account = accounts.value.claude.find((acc) => acc.id === key.claudeAccountId)
if (!account) {
return `⚠️ ${info} (账户不存在)`
}
if (account.accountType === 'dedicated') {
return `🔒 专属-${info}`
}
return info
}
if (key.claudeConsoleAccountId) {
const account = accounts.value.claude.find(
(acc) => acc.id === key.claudeConsoleAccountId && acc.platform === 'claude-console'
)
if (!account) {
return `⚠️ Console账户不存在`
}
return `Console-${account.name}`
}
return ''
}
// 获取Gemini绑定信息
const getGeminiBindingInfo = (key) => {
if (key.geminiAccountId) {
const info = getBoundAccountName(key.geminiAccountId)
if (key.geminiAccountId.startsWith('group:')) {
return info
}
// 检查账户是否存在
const account = accounts.value.gemini.find((acc) => acc.id === key.geminiAccountId)
if (!account) {
return `⚠️ ${info} (账户不存在)`
}
if (account.accountType === 'dedicated') {
return `🔒 专属-${info}`
}
return info
}
return ''
}
// 获取OpenAI绑定信息
const getOpenAIBindingInfo = (key) => {
if (key.openaiAccountId) {
const info = getBoundAccountName(key.openaiAccountId)
if (key.openaiAccountId.startsWith('group:')) {
return info
}
// 检查账户是否存在
const account = accounts.value.openai.find((acc) => acc.id === key.openaiAccountId)
if (!account) {
return `⚠️ ${info} (账户不存在)`
}
if (account.accountType === 'dedicated') {
return `🔒 专属-${info}`
}
return info
}
return ''
}
// 获取Bedrock绑定信息
const getBedrockBindingInfo = (key) => {
if (key.bedrockAccountId) {
const info = getBoundAccountName(key.bedrockAccountId)
if (key.bedrockAccountId.startsWith('group:')) {
return info
}
// 检查账户是否存在
const account = accounts.value.bedrock.find((acc) => acc.id === key.bedrockAccountId)
if (!account) {
return `⚠️ ${info} (账户不存在)`
}
if (account.accountType === 'dedicated') {
return `🔒 专属-${info}`
}
return info
}
return ''
}
// 检查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' : filter.preset === '7days' ? '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'
}
// 获取日期范围内的请求数
const getPeriodRequests = (key) => {
// 根据全局日期筛选器返回对应的请求数
if (globalDateFilter.type === 'custom') {
// 自定义日期范围
if (key.usage) {
if (key.usage['custom'] && key.usage['custom'].requests !== undefined) {
return key.usage['custom'].requests
}
if (key.usage.total && key.usage.total.requests !== undefined) {
return key.usage.total.requests
}
}
return 0
} else if (globalDateFilter.preset === 'today') {
return key.usage?.daily?.requests || 0
} else if (globalDateFilter.preset === '7days') {
// 使用 usage['7days'].requests
if (key.usage && key.usage['7days'] && key.usage['7days'].requests !== undefined) {
return key.usage['7days'].requests
}
return 0
} else if (globalDateFilter.preset === '30days') {
// 使用 usage['30days'].requests
if (key.usage) {
if (key.usage['30days'] && key.usage['30days'].requests !== undefined) {
return key.usage['30days'].requests
}
if (key.usage.monthly && key.usage.monthly.requests !== undefined) {
return key.usage.monthly.requests
}
}
return 0
} else if (globalDateFilter.preset === 'all') {
// 全部时间
if (key.usage && key.usage['all'] && key.usage['all'].requests !== undefined) {
return key.usage['all'].requests
}
return key.usage?.total?.requests || 0
} else {
// 默认返回
return key.usage?.total?.requests || 0
}
}
// 获取日期范围内的费用
const getPeriodCost = (key) => {
// 根据全局日期筛选器返回对应的费用
if (globalDateFilter.type === 'custom') {
// 自定义日期范围,使用服务器返回的 usage['custom'].cost
if (key.usage) {
if (key.usage['custom'] && key.usage['custom'].cost !== undefined) {
return key.usage['custom'].cost
}
if (key.usage.total && key.usage.total.cost !== undefined) {
return key.usage.total.cost
}
}
return 0
} else if (globalDateFilter.preset === 'today') {
return key.dailyCost || 0
} else if (globalDateFilter.preset === '7days') {
// 使用 usage['7days'].cost
if (key.usage && key.usage['7days'] && key.usage['7days'].cost !== undefined) {
return key.usage['7days'].cost
}
return key.weeklyCost || key.periodCost || 0
} else if (globalDateFilter.preset === '30days') {
// 使用 usage['30days'].cost 或 usage.monthly.cost
if (key.usage) {
if (key.usage['30days'] && key.usage['30days'].cost !== undefined) {
return key.usage['30days'].cost
}
if (key.usage.monthly && key.usage.monthly.cost !== undefined) {
return key.usage.monthly.cost
}
if (key.usage.total && key.usage.total.cost !== undefined) {
return key.usage.total.cost
}
}
return key.monthlyCost || key.periodCost || 0
} else if (globalDateFilter.preset === 'all') {
// 全部时间,返回 usage['all'].cost 或 totalCost
if (key.usage && key.usage['all'] && key.usage['all'].cost !== undefined) {
return key.usage['all'].cost
}
return key.totalCost || 0
} else {
// 默认返回 usage.total.cost
return key.periodCost || key.totalCost || 0
}
}
// 获取日期范围内的token数量
const getPeriodTokens = (key) => {
// 根据全局日期筛选器返回对应的token数量
if (globalDateFilter.type === 'custom') {
// 自定义日期范围
if (key.usage) {
if (key.usage['custom'] && key.usage['custom'].tokens !== undefined) {
return key.usage['custom'].tokens
}
if (key.usage.total && key.usage.total.tokens !== undefined) {
return key.usage.total.tokens
}
}
return 0
} else if (globalDateFilter.preset === 'today') {
return key.usage?.daily?.tokens || 0
} else if (globalDateFilter.preset === '7days') {
// 使用 usage['7days'].tokens
if (key.usage && key.usage['7days'] && key.usage['7days'].tokens !== undefined) {
return key.usage['7days'].tokens
}
return 0
} else if (globalDateFilter.preset === '30days') {
// 使用 usage['30days'].tokens 或 usage.monthly.tokens
if (key.usage) {
if (key.usage['30days'] && key.usage['30days'].tokens !== undefined) {
return key.usage['30days'].tokens
}
if (key.usage.monthly && key.usage.monthly.tokens !== undefined) {
return key.usage.monthly.tokens
}
if (key.usage.total && key.usage.total.tokens !== undefined) {
return key.usage.total.tokens
}
}
return 0
} else if (globalDateFilter.preset === 'all') {
// 全部时间
if (key.usage && key.usage['all'] && key.usage['all'].tokens !== undefined) {
return key.usage['all'].tokens
}
return key.usage?.total?.tokens || 0
} else {
// 默认返回
return key.usage?.total?.tokens || 0
}
}
// 计算日期范围内的总费用(用于展开的详细统计)
const calculatePeriodCost = (key) => {
// 如果没有展开,使用缓存的费用数据
if (!apiKeyModelStats.value[key.id]) {
return getPeriodCost(key)
}
// 计算所有模型的费用总和
const stats = apiKeyModelStats.value[key.id] || []
let totalCost = 0
stats.forEach((stat) => {
if (stat.cost !== undefined) {
totalCost += stat.cost
} else if (stat.formatted && stat.formatted.total) {
// 尝试从格式化的字符串中提取数字
const costStr = stat.formatted.total.replace('$', '').replace(',', '')
const cost = parseFloat(costStr)
if (!isNaN(cost)) {
totalCost += cost
}
}
})
return totalCost
}
// 处理时间范围下拉框变化
const handleTimeRangeChange = (value) => {
setGlobalDateFilterPreset(value)
}
// 设置全局日期预设
const setGlobalDateFilterPreset = (preset) => {
globalDateFilter.preset = preset
if (preset === 'custom') {
// 自定义选项,不自动设置日期,等待用户选择
globalDateFilter.type = 'custom'
// 如果没有自定义范围设置默认为最近7天
if (!globalDateFilter.customRange) {
const today = new Date()
const startDate = new Date(today)
startDate.setDate(today.getDate() - 6)
const formatDate = (date) => {
return (
date.getFullYear() +
'-' +
String(date.getMonth() + 1).padStart(2, '0') +
'-' +
String(date.getDate()).padStart(2, '0') +
' 00:00:00'
)
}
globalDateFilter.customRange = [formatDate(startDate), formatDate(today)]
globalDateFilter.customStart = startDate.toISOString().split('T')[0]
globalDateFilter.customEnd = today.toISOString().split('T')[0]
}
} else if (preset === 'all') {
// 全部时间选项
globalDateFilter.type = 'preset'
globalDateFilter.customStart = null
globalDateFilter.customEnd = null
} else {
// 预设选项7天或30天
globalDateFilter.type = 'preset'
const today = new Date()
const startDate = new Date(today)
if (preset === '7days') {
startDate.setDate(today.getDate() - 6)
} else if (preset === '30days') {
startDate.setDate(today.getDate() - 29)
}
globalDateFilter.customStart = startDate.toISOString().split('T')[0]
globalDateFilter.customEnd = today.toISOString().split('T')[0]
}
loadApiKeys()
}
// 全局自定义日期范围变化
const onGlobalCustomDateRangeChange = (value) => {
if (value && value.length === 2) {
globalDateFilter.type = 'custom'
globalDateFilter.preset = 'custom'
globalDateFilter.customRange = value
globalDateFilter.customStart = value[0].split(' ')[0]
globalDateFilter.customEnd = value[1].split(' ')[0]
loadApiKeys()
} else if (value === null) {
// 清空时恢复默认7天
setGlobalDateFilterPreset('7days')
}
}
// 初始化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 },
{ value: 'custom', label: '自定义', days: -1 }
]
}
}
// 获取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) {
if (preset === 'custom') {
// 自定义选项,不自动设置日期,等待用户选择
filter.type = 'custom'
// 如果没有自定义范围设置默认为最近7天
if (!filter.customRange) {
const today = new Date()
const startDate = new Date(today)
startDate.setDate(today.getDate() - 6)
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)]
filter.customStart = startDate.toISOString().split('T')[0]
filter.customEnd = today.toISOString().split('T')[0]
}
} else {
// 预设选项
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 = 'custom'
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 = async () => {
// 重新加载账号数据,确保显示最新的专属账号
await loadAccounts()
showCreateApiKeyModal.value = true
}
// 打开编辑模态框
const openEditApiKeyModal = async (apiKey) => {
// 重新加载账号数据,确保显示最新的专属账号
await loadAccounts()
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 handleBatchCreateSuccess = (data) => {
showCreateApiKeyModal.value = false
batchApiKeyData.value = data
showBatchApiKeyModal.value = true
loadApiKeys()
}
// 打开批量编辑模态框
const openBatchEditModal = async () => {
if (selectedApiKeys.value.length === 0) {
showToast('请先选择要编辑的 API Keys', 'warning')
return
}
// 重新加载账号数据,确保显示最新的专属账号
await loadAccounts()
showBatchEditModal.value = true
}
// 处理批量编辑成功
const handleBatchEditSuccess = () => {
showBatchEditModal.value = false
// 清空选中状态
selectedApiKeys.value = []
updateSelectAllState()
loadApiKeys()
}
// 处理编辑成功
const handleEditSuccess = () => {
showEditApiKeyModal.value = false
showToast('API Key 更新成功', 'success')
loadApiKeys()
}
// 处理续期成功
const handleRenewSuccess = () => {
showRenewApiKeyModal.value = false
showToast('API Key 续期成功', 'success')
loadApiKeys()
}
// 切换API Key状态激活/禁用)
const toggleApiKeyStatus = async (key) => {
let confirmed = true
// 禁用时需要二次确认
if (key.isActive) {
if (window.showConfirm) {
confirmed = await window.showConfirm(
'禁用 API Key',
`确定要禁用 API Key "${key.name}" 吗?禁用后所有使用此 Key 的请求将返回 401 错误。`,
'确定禁用',
'取消'
)
} else {
// 降级方案
confirmed = confirm(
`确定要禁用 API Key "${key.name}" 禁用后所有使用此 Key 的请求将返回 401 错误`
)
}
}
if (!confirmed) return
try {
const data = await apiClient.put(`/admin/api-keys/${key.id}`, {
isActive: !key.isActive
})
if (data.success) {
showToast(`API Key ${key.isActive ? '禁用' : '激活'}`, 'success')
// 更新本地数据
const localKey = apiKeys.value.find((k) => k.id === key.id)
if (localKey) {
localKey.isActive = !key.isActive
}
} else {
showToast(data.message || '操作失败', 'error')
}
} catch (error) {
showToast('操作失败', 'error')
}
}
// 删除API Key
const deleteApiKey = async (keyId) => {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'删除 API Key',
'确定要删除这个 API Key 吗?此操作不可恢复。',
'确定删除',
'取消'
)
} else {
// 降级方案
confirmed = confirm('确定要删除这个 API Key 吗?此操作不可恢复。')
}
if (!confirmed) return
try {
const data = await apiClient.delete(`/admin/api-keys/${keyId}`)
if (data.success) {
showToast('API Key 已删除', 'success')
// 从选中列表中移除
const index = selectedApiKeys.value.indexOf(keyId)
if (index > -1) {
selectedApiKeys.value.splice(index, 1)
}
updateSelectAllState()
loadApiKeys()
} else {
showToast(data.message || '删除失败', 'error')
}
} catch (error) {
showToast('删除失败', 'error')
}
}
// 恢复API Key
const restoreApiKey = async (keyId) => {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'恢复 API Key',
'确定要恢复这个 API Key 吗?恢复后可以重新使用。',
'确定恢复',
'取消'
)
} else {
// 降级方案
confirmed = confirm('确定要恢复这个 API Key 吗?恢复后可以重新使用。')
}
if (!confirmed) return
try {
const data = await apiClient.post(`/admin/api-keys/${keyId}/restore`)
if (data.success) {
showToast('API Key 已成功恢复', 'success')
// 刷新已删除列表
await loadDeletedApiKeys()
// 同时刷新活跃列表
await loadApiKeys()
} else {
showToast(data.error || '恢复失败', 'error')
}
} catch (error) {
showToast(error.response?.data?.error || '恢复失败', 'error')
}
}
// 彻底删除API Key
const permanentDeleteApiKey = async (keyId) => {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'彻底删除 API Key',
'确定要彻底删除这个 API Key 吗?此操作不可恢复,所有相关数据将被永久删除。',
'确定彻底删除',
'取消'
)
} else {
// 降级方案
confirmed = confirm('确定要彻底删除这个 API Key 吗?此操作不可恢复,所有相关数据将被永久删除。')
}
if (!confirmed) return
try {
const data = await apiClient.delete(`/admin/api-keys/${keyId}/permanent`)
if (data.success) {
showToast('API Key 已彻底删除', 'success')
// 刷新已删除列表
loadDeletedApiKeys()
} else {
showToast(data.error || '彻底删除失败', 'error')
}
} catch (error) {
showToast(error.response?.data?.error || '彻底删除失败', 'error')
}
}
// 清空所有已删除的API Keys
const clearAllDeletedApiKeys = async () => {
const count = deletedApiKeys.value.length
if (count === 0) {
showToast('没有需要清空的 API Keys', 'info')
return
}
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'清空所有已删除的 API Keys',
`确定要彻底删除全部 ${count} 个已删除的 API Keys 吗?此操作不可恢复,所有相关数据将被永久删除。`,
'确定清空全部',
'取消'
)
} else {
// 降级方案
confirmed = confirm(`确定要彻底删除全部 ${count} 个已删除的 API Keys 吗?此操作不可恢复。`)
}
if (!confirmed) return
try {
const data = await apiClient.delete('/admin/api-keys/deleted/clear-all')
if (data.success) {
showToast(data.message || '已清空所有已删除的 API Keys', 'success')
// 如果有失败的,显示详细信息
if (data.details && data.details.failedCount > 0) {
const errors = data.details.errors
console.error('部分API Keys清空失败:', errors)
showToast(`${data.details.failedCount} 个清空失败,请查看控制台`, 'warning')
}
// 刷新已删除列表
loadDeletedApiKeys()
} else {
showToast(data.error || '清空失败', 'error')
}
} catch (error) {
showToast(error.response?.data?.error || '清空失败', 'error')
}
}
// 批量删除API Keys
const batchDeleteApiKeys = async () => {
const selectedCount = selectedApiKeys.value.length
if (selectedCount === 0) {
showToast('请先选择要删除的 API Keys', 'warning')
return
}
let confirmed = false
const message = `确定要删除选中的 ${selectedCount} 个 API Key 吗?此操作不可恢复。`
if (window.showConfirm) {
confirmed = await window.showConfirm('批量删除 API Keys', message, '确定删除', '取消')
} else {
confirmed = confirm(message)
}
if (!confirmed) return
const keyIds = [...selectedApiKeys.value]
try {
const data = await apiClient.delete('/admin/api-keys/batch', {
data: { keyIds }
})
if (data.success) {
const { successCount, failedCount, errors } = data.data
if (successCount > 0) {
showToast(`成功删除 ${successCount} 个 API Keys`, 'success')
// 如果有失败的,显示详细信息
if (failedCount > 0) {
const errorMessages = errors.map((e) => `${e.keyId}: ${e.error}`).join('\n')
showToast(`${failedCount} 个删除失败:\n${errorMessages}`, 'warning')
}
} else {
showToast('所有 API Keys 删除失败', 'error')
}
// 清空选中状态
selectedApiKeys.value = []
updateSelectAllState()
loadApiKeys()
} else {
showToast(data.message || '批量删除失败', 'error')
}
} catch (error) {
showToast('批量删除失败', 'error')
console.error('批量删除 API Keys 失败:', error)
}
}
// 处理全选/取消全选
const handleSelectAll = () => {
if (selectAllChecked.value) {
// 全选当前页的所有API Keys
paginatedApiKeys.value.forEach((key) => {
if (!selectedApiKeys.value.includes(key.id)) {
selectedApiKeys.value.push(key.id)
}
})
} else {
// 取消全选:只移除当前页的选中项,保留其他页面的选中项
const currentPageIds = new Set(paginatedApiKeys.value.map((key) => key.id))
selectedApiKeys.value = selectedApiKeys.value.filter((id) => !currentPageIds.has(id))
}
updateSelectAllState()
}
// 更新全选状态
const updateSelectAllState = () => {
const totalInCurrentPage = paginatedApiKeys.value.length
const selectedInCurrentPage = paginatedApiKeys.value.filter((key) =>
selectedApiKeys.value.includes(key.id)
).length
if (selectedInCurrentPage === 0) {
selectAllChecked.value = false
isIndeterminate.value = false
} else if (selectedInCurrentPage === totalInCurrentPage) {
selectAllChecked.value = true
isIndeterminate.value = false
} else {
selectAllChecked.value = false
isIndeterminate.value = true
}
}
// 开始编辑过期时间
const startEditExpiry = (apiKey) => {
editingExpiryKey.value = apiKey
}
// 关闭过期时间编辑
const closeExpiryEdit = () => {
editingExpiryKey.value = null
}
// 保存过期时间
const handleSaveExpiry = async ({ keyId, expiresAt, activateNow }) => {
try {
// 使用新的PATCH端点来修改过期时间
const data = await apiClient.patch(`/admin/api-keys/${keyId}/expiration`, {
expiresAt: expiresAt || null,
activateNow: activateNow || false
})
if (data.success) {
showToast(activateNow ? 'API Key已激活' : '过期时间已更新', 'success')
// 更新本地数据
const key = apiKeys.value.find((k) => k.id === keyId)
if (key) {
if (activateNow && data.updates) {
key.isActivated = true
key.activatedAt = data.updates.activatedAt
key.expiresAt = data.updates.expiresAt
} else {
key.expiresAt = expiresAt || null
if (expiresAt && !key.isActivated) {
key.isActivated = true
}
}
}
closeExpiryEdit()
} else {
showToast(data.message || '更新失败', 'error')
// 重置保存状态
if (expiryEditModalRef.value) {
expiryEditModalRef.value.resetSaving()
}
}
} catch (error) {
showToast('更新失败', 'error')
// 重置保存状态
if (expiryEditModalRef.value) {
expiryEditModalRef.value.resetSaving()
}
}
}
// 格式化日期时间
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date
.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
.replace(/\//g, '-')
}
// 获取每日费用进度
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'
}
// 获取 Opus 周费用进度
const getWeeklyOpusCostProgress = (key) => {
if (!key.weeklyOpusCostLimit || key.weeklyOpusCostLimit === 0) return 0
const percentage = ((key.weeklyOpusCost || 0) / key.weeklyOpusCostLimit) * 100
return Math.min(percentage, 100)
}
// 获取 Opus 周费用进度条颜色
const getWeeklyOpusCostProgressColor = (key) => {
const progress = getWeeklyOpusCostProgress(key)
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 获取总费用进度 - 暂时不用
// const getTotalCostProgress = (key) => {
// if (!key.totalCostLimit || key.totalCostLimit === 0) return 0
// const percentage = ((key.totalCost || 0) / key.totalCostLimit) * 100
// return Math.min(percentage, 100)
// }
// 显示使用详情
const showUsageDetails = (apiKey) => {
selectedApiKeyForDetail.value = apiKey
showUsageDetailModal.value = true
}
// 格式化最后使用时间
const formatLastUsed = (dateString) => {
if (!dateString) return '从未使用'
const date = new Date(dateString)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
return date.toLocaleDateString('zh-CN')
}
// 清除搜索
const clearSearch = () => {
searchKeyword.value = ''
currentPage.value = 1
}
// 导出数据到Excel
const exportToExcel = () => {
try {
// 准备导出的数据 - 仅导出用量数据
const exportData = sortedApiKeys.value.map((key) => {
// 基础数据
const baseData = {
'API Key名称': key.name || '',
所有者: key.ownerDisplayName || '',
// 当前筛选时间段的统计
请求总数: getPeriodRequests(key),
'总费用($)': calculatePeriodCost(key).toFixed(4),
总Token数: getPeriodTokens(key),
// 今日统计
今日请求数: key.usage?.daily?.requests || 0,
'今日费用($)': (key.dailyCost || 0).toFixed(4),
今日Token数: key.usage?.daily?.totalTokens || 0,
// 累计总统计
累计总请求数: key.usage?.total?.requests || 0,
'累计总费用($)': (key.totalCost || 0).toFixed(4),
累计总Token数: key.usage?.total?.totalTokens || 0
}
// 添加分模型统计(如果有)
const modelStats = {}
// 处理每日模型统计
if (key.usage?.daily?.models) {
Object.entries(key.usage.daily.models).forEach(([model, stats]) => {
const modelName = model.replace(/[:/]/g, '_') // 处理模型名中的特殊字符
modelStats[`今日_${modelName}_请求数`] = stats.requests || 0
modelStats[`今日_${modelName}_费用($)`] = (stats.cost || 0).toFixed(4)
modelStats[`今日_${modelName}_输入Token`] = stats.inputTokens || 0
modelStats[`今日_${modelName}_输出Token`] = stats.outputTokens || 0
modelStats[`今日_${modelName}_总Token`] = stats.totalTokens || 0
})
}
// 处理总计模型统计
if (key.usage?.total?.models) {
Object.entries(key.usage.total.models).forEach(([model, stats]) => {
const modelName = model.replace(/[:/]/g, '_')
modelStats[`累计_${modelName}_请求数`] = stats.requests || 0
modelStats[`累计_${modelName}_费用($)`] = (stats.cost || 0).toFixed(4)
modelStats[`累计_${modelName}_输入Token`] = stats.inputTokens || 0
modelStats[`累计_${modelName}_输出Token`] = stats.outputTokens || 0
modelStats[`累计_${modelName}_总Token`] = stats.totalTokens || 0
})
}
// 处理筛选时间段的模型统计
if (globalDateFilter.preset === '7days' && key.usage?.weekly?.models) {
Object.entries(key.usage.weekly.models).forEach(([model, stats]) => {
const modelName = model.replace(/[:/]/g, '_')
modelStats[`本周_${modelName}_请求数`] = stats.requests || 0
modelStats[`本周_${modelName}_费用($)`] = (stats.cost || 0).toFixed(4)
modelStats[`本周_${modelName}_输入Token`] = stats.inputTokens || 0
modelStats[`本周_${modelName}_输出Token`] = stats.outputTokens || 0
modelStats[`本周_${modelName}_总Token`] = stats.totalTokens || 0
})
} else if (globalDateFilter.preset === '30days' && key.usage?.monthly?.models) {
Object.entries(key.usage.monthly.models).forEach(([model, stats]) => {
const modelName = model.replace(/[:/]/g, '_')
modelStats[`本月_${modelName}_请求数`] = stats.requests || 0
modelStats[`本月_${modelName}_费用($)`] = (stats.cost || 0).toFixed(4)
modelStats[`本月_${modelName}_输入Token`] = stats.inputTokens || 0
modelStats[`本月_${modelName}_输出Token`] = stats.outputTokens || 0
modelStats[`本月_${modelName}_总Token`] = stats.totalTokens || 0
})
}
return { ...baseData, ...modelStats }
})
// 创建工作簿
const ws = XLSX.utils.json_to_sheet(exportData)
// 动态设置列宽
const headers = Object.keys(exportData[0] || {})
const colWidths = headers.map((header) => {
if (header.includes('名称') || header.includes('所有者')) return { wch: 20 }
if (header.includes('费用')) return { wch: 15 }
if (header.includes('Token')) return { wch: 15 }
if (header.includes('请求')) return { wch: 12 }
return { wch: 15 }
})
ws['!cols'] = colWidths
// 创建工作簿
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '用量统计')
// 生成文件名(包含时间戳和筛选条件)
const now = new Date()
const timestamp =
now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
'_' +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0')
let timeRangeLabel = ''
if (globalDateFilter.type === 'preset') {
const presetLabels = {
today: '今日',
'7days': '最近7天',
'30days': '最近30天',
all: '全部时间'
}
timeRangeLabel = presetLabels[globalDateFilter.preset] || globalDateFilter.preset
} else {
timeRangeLabel = '自定义时间'
}
const filename = `API_Keys_用量统计_${timeRangeLabel}_${timestamp}.xlsx`
// 导出文件
XLSX.writeFile(wb, filename)
showToast(`成功导出 ${exportData.length} 条API Key用量数据`, 'success')
} catch (error) {
console.error('导出失败:', error)
showToast('导出失败,请重试', 'error')
}
}
// 监听筛选条件变化,重置页码和选中状态
// 监听筛选条件变化(不包括搜索),清空选中状态
watch([selectedTagFilter, apiKeyStatsTimeRange], () => {
currentPage.value = 1
// 清空选中状态
selectedApiKeys.value = []
updateSelectAllState()
})
// 监听搜索关键词变化,只重置分页,保持选中状态
watch(searchKeyword, () => {
currentPage.value = 1
// 不清空选中状态,允许跨搜索保持勾选
updateSelectAllState()
})
// 监听分页变化,更新全选状态
watch([currentPage, pageSize], () => {
updateSelectAllState()
})
// 监听API Keys数据变化清理无效的选中状态
watch(apiKeys, () => {
const validIds = new Set(apiKeys.value.map((key) => key.id))
// 过滤出仍然有效的选中项
selectedApiKeys.value = selectedApiKeys.value.filter((id) => validIds.has(id))
updateSelectAllState()
})
onMounted(async () => {
// 并行加载所有需要的数据
await Promise.all([clientsStore.loadSupportedClients(), loadAccounts(), loadApiKeys()])
// 初始化全选状态
updateSelectAllState()
})
</script>
<style scoped>
.tab-content {
min-height: calc(100vh - 300px);
}
.table-wrapper {
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
width: 100%;
}
.table-container {
overflow-x: auto;
overflow-y: hidden;
margin: 0;
padding: 0;
max-width: 100%;
}
/* 防止表格内容溢出 */
.table-container table {
min-width: 100%;
border-collapse: collapse;
}
.table-container::-webkit-scrollbar {
height: 8px;
}
.table-container::-webkit-scrollbar-track {
background: #f3f4f6;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
.table-row {
transition: background-color 0.2s ease;
position: relative;
}
.table-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.dark .table-row:hover {
background-color: rgba(255, 255, 255, 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 border-gray-300 bg-white focus:border-blue-500 focus:ring-blue-500;
}
.api-key-date-picker :deep(.el-range-separator) {
@apply text-gray-500;
}
/* 自定义日期范围选择器高度对齐 */
.custom-date-range-picker :deep(.el-input__wrapper) {
@apply h-[38px] rounded-lg border border-gray-200 bg-white shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800;
}
.custom-date-range-picker :deep(.el-input__inner) {
@apply h-full py-2 text-sm font-medium text-gray-700 dark:text-gray-200;
}
.custom-date-range-picker :deep(.el-input__prefix),
.custom-date-range-picker :deep(.el-input__suffix) {
@apply flex items-center;
}
.custom-date-range-picker :deep(.el-range-separator) {
@apply mx-2 text-gray-500;
}
</style>