Files
claude-relay-service/web/admin-spa/src/views/ApiKeysView.vue

1666 lines
61 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 sm:mb-2 sm:text-xl">API Keys 管理</h3>
<p class="text-sm text-gray-600 sm:text-base">管理和监控您的 API 密钥</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<!-- Token统计时间范围选择 -->
<div class="flex flex-wrap items-center gap-2">
<select
v-model="apiKeyStatsTimeRange"
class="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:border-gray-300 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="loadApiKeys()"
>
<option value="today">今日</option>
<option value="7days">最近7天</option>
<option value="monthly">本月</option>
<option value="all">全部时间</option>
</select>
<!-- 标签筛选器 -->
<select
v-model="selectedTagFilter"
class="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:border-gray-300 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="currentPage = 1"
>
<option value="">所有标签</option>
<option v-for="tag in availableTags" :key="tag" :value="tag">
{{ tag }}
</option>
</select>
</div>
<button
class="btn btn-primary flex w-full items-center justify-center gap-2 px-4 py-2 text-sm sm:w-auto"
@click.stop="openCreateApiKeyModal"
>
<i class="fas fa-plus" />创建新 Key
</button>
</div>
</div>
<div v-if="apiKeysLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500">正在加载 API Keys...</p>
</div>
<div v-else-if="apiKeys.length === 0" class="py-12 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100"
>
<i class="fas fa-key text-xl text-gray-400" />
</div>
<p class="text-lg text-gray-500">暂无 API Keys</p>
<p class="mt-2 text-sm text-gray-400">点击上方按钮创建您的第一个 API Key</p>
</div>
<!-- 桌面端表格视图 -->
<div v-else class="table-container hidden md:block">
<table class="w-full table-fixed">
<thead class="bg-gray-50/80 backdrop-blur-sm">
<tr>
<th
class="w-[25%] min-w-[200px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100"
@click="sortApiKeys('name')"
>
名称
<i
v-if="apiKeysSortBy === 'name'"
:class="[
'fas',
apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="w-[10%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700"
>
标签
</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"
@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-[17%] min-w-[140px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700"
>
使用统计
<span
class="cursor-pointer rounded px-2 py-1 hover:bg-gray-100"
@click="sortApiKeys('cost')"
>
(费用
<i
v-if="apiKeysSortBy === 'cost'"
:class="[
'fas',
apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />)
</span>
</th>
<th
class="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"
@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"
@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"
>
操作
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50">
<template v-for="key in paginatedApiKeys" :key="key.id">
<!-- API Key 主行 -->
<tr class="table-row">
<td class="px-3 py-4">
<div class="flex items-center">
<div
class="mr-2 flex h-8 w-8 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-xs text-white" />
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-gray-900" :title="key.name">
{{ key.name }}
</div>
<div class="truncate text-xs text-gray-500" :title="key.id">
{{ key.id }}
</div>
<div class="mt-1 truncate text-xs text-gray-500">
<span
v-if="key.claudeAccountId"
:title="`绑定: ${getBoundAccountName(key.claudeAccountId)}`"
>
<i class="fas fa-link mr-1" />
{{ getBoundAccountName(key.claudeAccountId) }}
</span>
<span v-else>
<i class="fas fa-share-alt mr-1" />
共享池
</span>
</div>
</div>
</div>
</td>
<td class="px-3 py-4">
<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"
>
{{ 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-4">
<span
:class="[
'inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold',
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
]"
>
<div
:class="[
'mr-2 h-2 w-2 rounded-full',
key.isActive ? 'bg-green-500' : 'bg-red-500'
]"
/>
{{ key.isActive ? '活跃' : '禁用' }}
</span>
</td>
<td class="px-3 py-4">
<div class="space-y-2">
<!-- 今日使用统计 -->
<div class="mb-2">
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-gray-600">今日请求</span>
<span class="font-semibold text-gray-900"
>{{ formatNumber(key.usage?.daily?.requests || 0) }}</span
>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">今日费用</span>
<span class="font-semibold text-green-600"
>${{ (key.dailyCost || 0).toFixed(4) }}</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">费用限额</span>
<span class="text-gray-700">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{
key.dailyCostLimit.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="getDailyCostProgressColor(key)"
:style="{ width: getDailyCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- 时间窗口限制进度条 -->
<WindowCountdown
v-if="key.rateLimitWindow > 0"
: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 class="pt-1">
<button
class="flex w-full items-center justify-center gap-1 rounded py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-800"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line" />
查看详细统计
</button>
</div>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{{ new Date(key.createdAt).toLocaleDateString() }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<div class="inline-flex items-center gap-1.5">
<span v-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">
{{ formatExpireDate(key.expiresAt) }}
</span>
</span>
<span v-else class="inline-flex items-center text-gray-400">
<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"
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-4 text-sm">
<div class="flex gap-1">
<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"
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-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900"
title="复制统计页面链接"
@click="copyApiStatsLink(key)"
>
<i class="fas fa-chart-bar" />
<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"
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"
title="续期"
@click="openRenewApiKeyModal(key)"
>
<i class="fas fa-clock" />
<span class="ml-1 hidden xl:inline">续期</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"
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-4" colspan="7">
<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">加载模型统计...</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">
<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"
>
{{ 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">
<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'
: 'text-gray-600 hover:text-gray-900'
]"
@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">暂无模型使用数据</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"
>
<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">{{
stat.model
}}</span>
<span class="rounded-full bg-blue-50 px-2 py-1 text-xs text-gray-500"
>{{ 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">
<i class="fas fa-coins mr-1 text-xs text-yellow-500" />
总Token:
</span>
<span class="font-semibold text-gray-900">{{
formatTokenCount(stat.allTokens)
}}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="flex items-center text-gray-600">
<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">
<div class="flex items-center justify-between text-xs text-gray-500">
<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">
<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">
<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"
>
<div class="flex items-center justify-between text-sm">
<span class="flex items-center font-semibold text-gray-700">
<i class="fas fa-calculator mr-2 text-indigo-500" />
总计统计
</span>
<div class="flex gap-4 text-xs">
<span class="text-gray-600">
总请求:
<span class="font-semibold text-gray-800">{{
apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.requests, 0)
}}</span>
</span>
<span class="text-gray-600">
总Token:
<span class="font-semibold text-gray-800">{{
formatTokenCount(
apiKeyModelStats[key.id].reduce(
(sum, stat) => sum + stat.allTokens,
0
)
)
}}</span>
</span>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</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">
<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">
{{ key.name }}
</h4>
<p class="mt-0.5 text-xs text-gray-500">
{{ 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' : 'bg-red-100 text-red-800'
]"
>
<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 text-xs text-gray-600">
<span v-if="key.claudeAccountId || key.claudeConsoleAccountId">
<i class="fas fa-link mr-1" />
绑定: {{ getBoundAccountName(key.claudeAccountId, key.claudeConsoleAccountId) }}
</span>
<span v-else>
<i class="fas fa-share-alt mr-1" />
使用共享池
</span>
</div>
<!-- 统计信息 -->
<div class="mb-3 space-y-2">
<!-- 今日使用 -->
<div class="rounded-lg bg-gray-50 p-3">
<div class="mb-2 flex items-center justify-between">
<span class="text-xs text-gray-600">今日使用</span>
<button
class="text-xs text-blue-600 hover:text-blue-800"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line mr-1" />详情
</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-sm font-semibold text-gray-900">
{{ formatNumber(key.usage?.daily?.requests || 0) }} 次
</p>
<p class="text-xs text-gray-500">请求</p>
</div>
<div>
<p class="text-sm font-semibold text-green-600">
${{ (key.dailyCost || 0).toFixed(4) }}
</p>
<p class="text-xs text-gray-500">费用</p>
</div>
</div>
</div>
<!-- 限制进度 -->
<div v-if="key.dailyCostLimit > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">每日费用限额</span>
<span class="text-gray-700">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200">
<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)"
: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">
<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"
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"
>
{{ tag }}
</span>
</div>
<!-- 操作按钮 -->
<div class="mt-3 flex gap-2 border-t border-gray-100 pt-3">
<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"
@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"
@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"
@click="openRenewApiKeyModal(key)"
>
<i class="fas fa-clock mr-1" />
续期
</button>
<button
class="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600 transition-colors hover:bg-red-100"
@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 sm:text-sm">
共 {{ sortedApiKeys.length }} 条记录
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-600 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 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 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 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 sm:block"
@click="currentPage = 1"
>
1
</button>
<span v-if="currentPage > 4" class="hidden px-2 text-gray-500 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'
]"
@click="currentPage = page"
>
{{ page }}
</button>
<!-- 最后一页 -->
<span v-if="currentPage < totalPages - 3" class="hidden px-2 text-gray-500 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 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 sm:py-1 sm:text-sm"
:disabled="currentPage === totalPages || totalPages === 0"
@click="currentPage++"
>
<i class="fas fa-chevron-right" />
</button>
</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"
/>
<!-- 过期时间编辑弹窗 -->
<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, computed, onMounted, watch } from 'vue'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import { useClientsStore } from '@/stores/clients'
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
// 响应式数据
const clientsStore = useClientsStore()
const apiKeys = ref([])
const apiKeysLoading = ref(false)
const apiKeyStatsTimeRange = ref('today')
const apiKeysSortBy = ref('')
const apiKeysSortOrder = ref('asc')
const expandedApiKeys = ref({})
const apiKeyModelStats = ref({})
const apiKeyDateFilters = ref({})
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
const accounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
const editingExpiryKey = ref(null)
const expiryEditModalRef = ref(null)
const showUsageDetailModal = ref(false)
const selectedApiKeyForDetail = ref(null)
// 标签相关
const selectedTagFilter = ref('')
const availableTags = ref([])
// 分页相关
const 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 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)
)
}
// 如果没有排序字段,返回筛选后的结果
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 === 'cost') {
aVal = parseFloat(calculateApiKeyCost(a.usage).replace('$', ''))
bVal = parseFloat(calculateApiKeyCost(b.usage).replace('$', ''))
} else if (apiKeysSortBy.value === 'createdAt' || apiKeysSortBy.value === 'expiresAt') {
aVal = aVal ? new Date(aVal).getTime() : 0
bVal = bVal ? new Date(bVal).getTime() : 0
}
if (aVal < bVal) return apiKeysSortOrder.value === 'asc' ? -1 : 1
if (aVal > bVal) return apiKeysSortOrder.value === 'asc' ? 1 : -1
return 0
})
return sorted
})
// 计算总页数
const 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, groupsData] = await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/account-groups')
])
if (claudeData.success) {
accounts.value.claude = claudeData.data || []
}
if (claudeConsoleData.success) {
// 将 Claude Console 账号合并到 claude 数组中
const consoleAccounts = (claudeConsoleData.data || []).map((acc) => ({
...acc,
platform: 'claude-console'
}))
accounts.value.claude = [...accounts.value.claude, ...consoleAccounts]
}
if (geminiData.success) {
accounts.value.gemini = geminiData.data || []
}
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')
}
} catch (error) {
console.error('加载账户列表失败:', error)
}
}
// 加载API Keys
const loadApiKeys = async () => {
apiKeysLoading.value = true
try {
const data = await apiClient.get(`/admin/api-keys?timeRange=${apiKeyStatsTimeRange.value}`)
if (data.success) {
apiKeys.value = data.data || []
// 更新可用标签列表
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 sortApiKeys = (field) => {
if (apiKeysSortBy.value === field) {
apiKeysSortOrder.value = apiKeysSortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
apiKeysSortBy.value = field
apiKeysSortOrder.value = 'asc'
}
}
// 格式化数字
const formatNumber = (num) => {
if (!num && num !== 0) return '0'
return num.toLocaleString('zh-CN')
}
// 计算API Key费用
const calculateApiKeyCost = (usage) => {
if (!usage || !usage.total) return '$0.0000'
const cost = usage.total.cost || 0
return `$${cost.toFixed(4)}`
}
// 获取绑定账户名称
const getBoundAccountName = (accountId) => {
if (!accountId) return '未知账户'
// 检查是否是分组
if (accountId.startsWith('group:')) {
const groupId = accountId.substring(6) // 移除 'group:' 前缀
// 从Claude分组中查找
const claudeGroup = accounts.value.claudeGroups.find((g) => g.id === groupId)
if (claudeGroup) {
return `分组-${claudeGroup.name}`
}
// 从Gemini分组中查找
const geminiGroup = accounts.value.geminiGroups.find((g) => g.id === groupId)
if (geminiGroup) {
return `分组-${geminiGroup.name}`
}
// 如果找不到分组返回分组ID的前8位
return `分组-${groupId.substring(0, 8)}`
}
// 从Claude账户列表中查找
const claudeAccount = accounts.value.claude.find((acc) => acc.id === accountId)
if (claudeAccount) {
return `账户-${claudeAccount.name}`
}
// 从Gemini账户列表中查找
const geminiAccount = accounts.value.gemini.find((acc) => acc.id === accountId)
if (geminiAccount) {
return `账户-${geminiAccount.name}`
}
// 如果找不到返回账户ID的前8位
return `账户-${accountId.substring(0, 8)}`
}
// 检查API Key是否过期
const isApiKeyExpired = (expiresAt) => {
if (!expiresAt) return false
return new Date(expiresAt) < new Date()
}
// 检查API Key是否即将过期
const isApiKeyExpiringSoon = (expiresAt) => {
if (!expiresAt || isApiKeyExpired(expiresAt)) return false
const daysUntilExpiry = (new Date(expiresAt) - new Date()) / (1000 * 60 * 60 * 24)
return daysUntilExpiry <= 7
}
// 格式化过期日期
const formatExpireDate = (dateString) => {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString('zh-CN')
}
// 切换模型统计展开状态
const toggleApiKeyModelStats = async (keyId) => {
if (!expandedApiKeys.value[keyId]) {
expandedApiKeys.value[keyId] = true
// 初始化日期筛选器
if (!apiKeyDateFilters.value[keyId]) {
initApiKeyDateFilter(keyId)
}
// 加载模型统计数据
await loadApiKeyModelStats(keyId, true)
} else {
expandedApiKeys.value[keyId] = false
}
}
// 加载 API Key 的模型统计
const loadApiKeyModelStats = async (keyId, forceReload = false) => {
if (!forceReload && apiKeyModelStats.value[keyId] && apiKeyModelStats.value[keyId].length > 0) {
return
}
const filter = getApiKeyDateFilter(keyId)
try {
let url = `/admin/api-keys/${keyId}/model-stats`
const params = new URLSearchParams()
if (filter.customStart && filter.customEnd) {
params.append('startDate', filter.customStart)
params.append('endDate', filter.customEnd)
params.append('period', 'custom')
} else {
const period =
filter.preset === 'today' ? 'daily' : 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'
}
// 初始化API Key的日期筛选器
const initApiKeyDateFilter = (keyId) => {
const today = new Date()
const startDate = new Date(today)
startDate.setDate(today.getDate() - 6) // 7天前
apiKeyDateFilters.value[keyId] = {
type: 'preset',
preset: '7days',
customStart: startDate.toISOString().split('T')[0],
customEnd: today.toISOString().split('T')[0],
customRange: null,
presetOptions: [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
}
}
// 获取API Key的日期筛选器状态
const getApiKeyDateFilter = (keyId) => {
if (!apiKeyDateFilters.value[keyId]) {
initApiKeyDateFilter(keyId)
}
return apiKeyDateFilters.value[keyId]
}
// 设置 API Key 日期预设
const setApiKeyDateFilterPreset = (preset, keyId) => {
const filter = getApiKeyDateFilter(keyId)
filter.type = 'preset'
filter.preset = preset
const option = filter.presetOptions.find((opt) => opt.value === preset)
if (option) {
const today = new Date()
const startDate = new Date(today)
startDate.setDate(today.getDate() - (option.days - 1))
filter.customStart = startDate.toISOString().split('T')[0]
filter.customEnd = today.toISOString().split('T')[0]
const formatDate = (date) => {
return (
date.getFullYear() +
'-' +
String(date.getMonth() + 1).padStart(2, '0') +
'-' +
String(date.getDate()).padStart(2, '0') +
' 00:00:00'
)
}
filter.customRange = [formatDate(startDate), formatDate(today)]
}
loadApiKeyModelStats(keyId, true)
}
// API Key 自定义日期范围变化
const onApiKeyCustomDateRangeChange = (keyId, value) => {
const filter = getApiKeyDateFilter(keyId)
if (value && value.length === 2) {
filter.type = 'custom'
filter.preset = ''
filter.customRange = value
filter.customStart = value[0].split(' ')[0]
filter.customEnd = value[1].split(' ')[0]
loadApiKeyModelStats(keyId, true)
} else if (value === null) {
// 清空时恢复默认7天
setApiKeyDateFilterPreset('7days', keyId)
}
}
// 禁用未来日期
const disabledDate = (date) => {
return date > new Date()
}
// 重置API Key日期筛选器
const resetApiKeyDateFilter = (keyId) => {
const filter = getApiKeyDateFilter(keyId)
// 重置为默认的7天
filter.type = 'preset'
filter.preset = '7days'
const today = new Date()
const startDate = new Date(today)
startDate.setDate(today.getDate() - 6)
filter.customStart = startDate.toISOString().split('T')[0]
filter.customEnd = today.toISOString().split('T')[0]
filter.customRange = null
// 重新加载数据
loadApiKeyModelStats(keyId, true)
showToast('已重置筛选条件并刷新数据', 'info')
}
// 打开创建模态框
const openCreateApiKeyModal = 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 handleEditSuccess = () => {
showEditApiKeyModal.value = false
showToast('API Key 更新成功', 'success')
loadApiKeys()
}
// 处理续期成功
const handleRenewSuccess = () => {
showRenewApiKeyModal.value = false
showToast('API Key 续期成功', 'success')
loadApiKeys()
}
// 删除API Key
const deleteApiKey = async (keyId) => {
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')
loadApiKeys()
} else {
showToast(data.message || '删除失败', 'error')
}
} catch (error) {
showToast('删除失败', 'error')
}
}
// 复制API统计页面链接
const copyApiStatsLink = (apiKey) => {
// 构建统计页面的完整URL
const baseUrl = window.location.origin
const statsUrl = `${baseUrl}/admin-next/api-stats?apiId=${apiKey.id}`
// 使用传统的textarea方法复制到剪贴板
const textarea = document.createElement('textarea')
textarea.value = statsUrl
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, 99999) // 兼容移动端
try {
const successful = document.execCommand('copy')
if (successful) {
showToast(`已复制统计页面链接`, 'success')
} else {
showToast('复制失败,请手动复制', 'error')
console.log('统计页面链接:', statsUrl)
}
} catch (err) {
showToast('复制失败,请手动复制', 'error')
console.error('复制错误:', err)
console.log('统计页面链接:', statsUrl)
} finally {
document.body.removeChild(textarea)
}
}
// 开始编辑过期时间
const startEditExpiry = (apiKey) => {
editingExpiryKey.value = apiKey
}
// 关闭过期时间编辑
const closeExpiryEdit = () => {
editingExpiryKey.value = null
}
// 保存过期时间
const handleSaveExpiry = async ({ keyId, expiresAt }) => {
try {
const data = await apiClient.put(`/admin/api-keys/${keyId}`, {
expiresAt: expiresAt || null
})
if (data.success) {
showToast('过期时间已更新', 'success')
// 更新本地数据
const key = apiKeys.value.find((k) => k.id === keyId)
if (key) {
key.expiresAt = expiresAt || null
}
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'
}
// 显示使用详情
const showUsageDetails = (apiKey) => {
selectedApiKeyForDetail.value = apiKey
showUsageDetailModal.value = true
}
// 格式化Token数量使用K/M单位
const formatTokenCount = (count) => {
if (count >= 1000000) {
return (count / 1000000).toFixed(1) + 'M'
} else if (count >= 1000) {
return (count / 1000).toFixed(1) + 'K'
}
return count.toString()
}
// 监听筛选条件变化,重置页码
watch([selectedTagFilter, apiKeyStatsTimeRange], () => {
currentPage.value = 1
})
onMounted(async () => {
// 并行加载所有需要的数据
await Promise.all([clientsStore.loadSupportedClients(), loadAccounts(), loadApiKeys()])
})
</script>
<style scoped>
.tab-content {
min-height: calc(100vh - 300px);
}
.table-container {
overflow-x: auto;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e5e7eb;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.api-key-date-picker :deep(.el-input__inner) {
@apply 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;
}
</style>