|
|
<template> |
|
|
<div class="accounts-container"> |
|
|
<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"> |
|
|
账户管理 |
|
|
</h3> |
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base"> |
|
|
管理 Claude、Gemini、OpenAI 等账户与代理配置 |
|
|
</p> |
|
|
</div> |
|
|
<div class="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-[160px]"> |
|
|
<div |
|
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-indigo-500 to-blue-500 opacity-0 blur transition duration-300 group-hover:opacity-20" |
|
|
></div> |
|
|
<CustomDropdown |
|
|
v-model="accountSortBy" |
|
|
icon="fa-sort-amount-down" |
|
|
icon-color="text-indigo-500" |
|
|
:options="sortOptions" |
|
|
placeholder="选择排序" |
|
|
@change="sortAccounts()" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="group relative min-w-[140px]"> |
|
|
<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> |
|
|
<CustomDropdown |
|
|
v-model="platformFilter" |
|
|
icon="fa-server" |
|
|
icon-color="text-blue-500" |
|
|
:options="platformOptions" |
|
|
placeholder="选择平台" |
|
|
@change="filterByPlatform" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="group relative min-w-[160px]"> |
|
|
<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> |
|
|
<CustomDropdown |
|
|
v-model="groupFilter" |
|
|
icon="fa-layer-group" |
|
|
icon-color="text-purple-500" |
|
|
:options="groupOptions" |
|
|
placeholder="选择分组" |
|
|
@change="filterByGroup" |
|
|
/> |
|
|
</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="h-10 w-full rounded-lg border border-gray-200 bg-white px-3 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="搜索账户名称..." |
|
|
type="text" |
|
|
/> |
|
|
<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> |
|
|
</div> |
|
|
|
|
|
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center sm:gap-3"> |
|
|
|
|
|
<div class="relative"> |
|
|
<el-tooltip |
|
|
content="刷新数据 (Ctrl/⌘+点击强制刷新所有缓存)" |
|
|
effect="dark" |
|
|
placement="bottom" |
|
|
> |
|
|
<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-300 dark:hover:border-gray-500 sm:w-auto" |
|
|
:disabled="accountsLoading" |
|
|
@click.ctrl.exact="loadAccounts(true)" |
|
|
@click.exact="loadAccounts(false)" |
|
|
@click.meta.exact="loadAccounts(true)" |
|
|
> |
|
|
<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', |
|
|
accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt' |
|
|
]" |
|
|
/> |
|
|
<span class="relative">刷新</span> |
|
|
</button> |
|
|
</el-tooltip> |
|
|
</div> |
|
|
|
|
|
|
|
|
<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 |
|
|
v-if="selectedAccounts.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="batchDeleteAccounts" |
|
|
> |
|
|
<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">删除选中 ({{ selectedAccounts.length }})</span> |
|
|
</button> |
|
|
|
|
|
|
|
|
<button |
|
|
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-green-500 to-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all duration-200 hover:from-green-600 hover:to-green-700 hover:shadow-lg sm:w-auto" |
|
|
@click.stop="openCreateAccountModal" |
|
|
> |
|
|
<i class="fas fa-plus"></i> |
|
|
<span>添加账户</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div v-if="accountsLoading" class="py-12 text-center"> |
|
|
<div class="loading-spinner mx-auto mb-4" /> |
|
|
<p class="text-gray-500 dark:text-gray-400">正在加载账户...</p> |
|
|
</div> |
|
|
|
|
|
<div v-else-if="sortedAccounts.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-user-circle text-xl text-gray-400" /> |
|
|
</div> |
|
|
<p class="text-lg text-gray-500 dark:text-gray-400">暂无账户</p> |
|
|
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500">点击上方按钮添加您的第一个账户</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 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-[22%] min-w-[180px] 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="sortAccounts('name')" |
|
|
> |
|
|
名称 |
|
|
<i |
|
|
v-if="accountsSortBy === 'name'" |
|
|
:class="[ |
|
|
'fas', |
|
|
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', |
|
|
'ml-1' |
|
|
]" |
|
|
/> |
|
|
<i v-else class="fas fa-sort ml-1 text-gray-400" /> |
|
|
</th> |
|
|
<th |
|
|
class="w-[15%] min-w-[120px] 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="sortAccounts('platform')" |
|
|
> |
|
|
平台/类型 |
|
|
<i |
|
|
v-if="accountsSortBy === 'platform'" |
|
|
:class="[ |
|
|
'fas', |
|
|
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', |
|
|
'ml-1' |
|
|
]" |
|
|
/> |
|
|
<i v-else class="fas fa-sort ml-1 text-gray-400" /> |
|
|
</th> |
|
|
<th |
|
|
class="w-[12%] min-w-[110px] 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="sortAccounts('expiresAt')" |
|
|
> |
|
|
到期时间 |
|
|
<i |
|
|
v-if="accountsSortBy === 'expiresAt'" |
|
|
:class="[ |
|
|
'fas', |
|
|
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', |
|
|
'ml-1' |
|
|
]" |
|
|
/> |
|
|
<i v-else class="fas fa-sort ml-1 text-gray-400" /> |
|
|
</th> |
|
|
<th |
|
|
class="w-[12%] min-w-[100px] 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="sortAccounts('status')" |
|
|
> |
|
|
状态 |
|
|
<i |
|
|
v-if="accountsSortBy === 'status'" |
|
|
:class="[ |
|
|
'fas', |
|
|
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', |
|
|
'ml-1' |
|
|
]" |
|
|
/> |
|
|
<i v-else class="fas fa-sort ml-1 text-gray-400" /> |
|
|
</th> |
|
|
<th |
|
|
class="w-[8%] 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="sortAccounts('priority')" |
|
|
> |
|
|
优先级 |
|
|
<i |
|
|
v-if="accountsSortBy === 'priority'" |
|
|
:class="[ |
|
|
'fas', |
|
|
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', |
|
|
'ml-1' |
|
|
]" |
|
|
/> |
|
|
<i v-else class="fas fa-sort ml-1 text-gray-400" /> |
|
|
</th> |
|
|
<th |
|
|
class="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> |
|
|
<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-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" |
|
|
> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span>会话窗口</span> |
|
|
<el-tooltip placement="top"> |
|
|
<template #content> |
|
|
<div |
|
|
class="w-[260px] space-y-3 text-xs leading-relaxed text-white dark:text-gray-800" |
|
|
> |
|
|
<div class="space-y-2"> |
|
|
<div class="text-sm font-semibold text-white dark:text-gray-900"> |
|
|
Claude 系列 |
|
|
</div> |
|
|
<div class="text-gray-200 dark:text-gray-600"> |
|
|
会话窗口进度表示 5 小时窗口的时间推移,颜色提示当前调度状态。 |
|
|
</div> |
|
|
<div class="space-y-1 pt-1 text-gray-200 dark:text-gray-600"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div |
|
|
class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600" |
|
|
></div> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>正常:请求正常处理</span |
|
|
> |
|
|
</div> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div |
|
|
class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500" |
|
|
></div> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>警告:接近限制</span |
|
|
> |
|
|
</div> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div |
|
|
class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600" |
|
|
></div> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>拒绝:达到速率限制</span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="h-px bg-gray-200 dark:bg-gray-600/50"></div> |
|
|
<div class="space-y-2"> |
|
|
<div class="text-sm font-semibold text-white dark:text-gray-900"> |
|
|
OpenAI |
|
|
</div> |
|
|
<div class="text-gray-200 dark:text-gray-600"> |
|
|
进度条分别展示 5h 与周限窗口的额度使用比例,颜色含义与上方保持一致。 |
|
|
</div> |
|
|
<div class="space-y-1 text-gray-200 dark:text-gray-600"> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i class="fas fa-clock mt-[2px] text-[10px] text-blue-500"></i> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>5h 窗口:5小时使用量进度,到达重置时间后会自动归零。</span |
|
|
> |
|
|
</div> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i class="fas fa-history mt-[2px] text-[10px] text-emerald-500"></i> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>周限窗口:7天使用量进度,重置时同样回到 0%。</span |
|
|
> |
|
|
</div> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i |
|
|
class="fas fa-info-circle mt-[2px] text-[10px] text-indigo-500" |
|
|
></i> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>当“重置剩余”为 0 时,进度条与百分比会同步清零。</span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="h-px bg-gray-200 dark:bg-gray-600/50"></div> |
|
|
<div class="space-y-2"> |
|
|
<div class="text-sm font-semibold text-white dark:text-gray-900"> |
|
|
Claude OAuth 账户 |
|
|
</div> |
|
|
<div class="text-gray-200 dark:text-gray-600"> |
|
|
展示三个窗口的使用率(utilization百分比),颜色含义同上。 |
|
|
</div> |
|
|
<div class="space-y-1 text-gray-200 dark:text-gray-600"> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i class="fas fa-clock mt-[2px] text-[10px] text-indigo-500"></i> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>5h 窗口:5小时滑动窗口的使用率。</span |
|
|
> |
|
|
</div> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i |
|
|
class="fas fa-calendar-alt mt-[2px] text-[10px] text-emerald-500" |
|
|
></i> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>7d 窗口:7天总限额的使用率。</span |
|
|
> |
|
|
</div> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i class="fas fa-gem mt-[2px] text-[10px] text-purple-500"></i> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>Opus 窗口:7天Opus模型专用限额。</span |
|
|
> |
|
|
</div> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i class="fas fa-sync-alt mt-[2px] text-[10px] text-blue-500"></i> |
|
|
<span class="font-medium text-white dark:text-gray-900" |
|
|
>到达重置时间后自动归零。</span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</template> |
|
|
<i |
|
|
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400" |
|
|
/> |
|
|
</el-tooltip> |
|
|
</div> |
|
|
</th> |
|
|
<th |
|
|
class="w-[8%] 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-[15%] 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"> |
|
|
<tr v-for="account in paginatedAccounts" :key="account.id" class="table-row"> |
|
|
<td v-if="shouldShowCheckboxes" class="px-3 py-3"> |
|
|
<div class="flex items-center"> |
|
|
<input |
|
|
v-model="selectedAccounts" |
|
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" |
|
|
type="checkbox" |
|
|
:value="account.id" |
|
|
@change="updateSelectAllState" |
|
|
/> |
|
|
</div> |
|
|
</td> |
|
|
<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-green-500 to-green-600" |
|
|
> |
|
|
<i class="fas fa-user-circle text-xs text-white" /> |
|
|
</div> |
|
|
<div class="min-w-0"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div |
|
|
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100" |
|
|
:title="account.name" |
|
|
> |
|
|
{{ account.name }} |
|
|
</div> |
|
|
<span |
|
|
v-if="account.accountType === 'dedicated'" |
|
|
class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800" |
|
|
> |
|
|
<i class="fas fa-lock mr-1" />专属 |
|
|
</span> |
|
|
<span |
|
|
v-else-if="account.accountType === 'group'" |
|
|
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800" |
|
|
> |
|
|
<i class="fas fa-layer-group mr-1" />分组调度 |
|
|
</span> |
|
|
<span |
|
|
v-else |
|
|
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800" |
|
|
> |
|
|
<i class="fas fa-share-alt mr-1" />共享 |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if="account.groupInfos && account.groupInfos.length > 0" |
|
|
class="my-2 flex flex-wrap items-center gap-2" |
|
|
> |
|
|
<span |
|
|
v-for="group in account.groupInfos" |
|
|
:key="group.id" |
|
|
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400" |
|
|
:title="`所属分组: ${group.name}`" |
|
|
> |
|
|
<i class="fas fa-folder mr-1" />{{ group.name }} |
|
|
</span> |
|
|
</div> |
|
|
<div |
|
|
class="truncate text-xs text-gray-500 dark:text-gray-400" |
|
|
:title="account.id" |
|
|
> |
|
|
{{ account.id }} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</td> |
|
|
<td class="px-3 py-4"> |
|
|
<div class="flex items-center gap-1"> |
|
|
|
|
|
<div |
|
|
v-if="account.platform === 'gemini'" |
|
|
class="flex items-center gap-1.5 rounded-lg border border-yellow-200 bg-gradient-to-r from-yellow-100 to-amber-100 px-2.5 py-1" |
|
|
> |
|
|
<i class="fas fa-robot text-xs text-yellow-700" /> |
|
|
<span class="text-xs font-semibold text-yellow-800">Gemini</span> |
|
|
<span class="mx-1 h-4 w-px bg-yellow-300" /> |
|
|
<span class="text-xs font-medium text-yellow-700"> |
|
|
{{ getGeminiAuthType() }} |
|
|
</span> |
|
|
</div> |
|
|
<div |
|
|
v-else-if="account.platform === 'claude-console'" |
|
|
class="flex items-center gap-1.5 rounded-lg border border-purple-200 bg-gradient-to-r from-purple-100 to-pink-100 px-2.5 py-1" |
|
|
> |
|
|
<i class="fas fa-terminal text-xs text-purple-700" /> |
|
|
<span class="text-xs font-semibold text-purple-800">Console</span> |
|
|
<span class="mx-1 h-4 w-px bg-purple-300" /> |
|
|
<span class="text-xs font-medium text-purple-700">API Key</span> |
|
|
</div> |
|
|
<div |
|
|
v-else-if="account.platform === 'bedrock'" |
|
|
class="flex items-center gap-1.5 rounded-lg border border-orange-200 bg-gradient-to-r from-orange-100 to-red-100 px-2.5 py-1" |
|
|
> |
|
|
<i class="fab fa-aws text-xs text-orange-700" /> |
|
|
<span class="text-xs font-semibold text-orange-800">Bedrock</span> |
|
|
<span class="mx-1 h-4 w-px bg-orange-300" /> |
|
|
<span class="text-xs font-medium text-orange-700">AWS</span> |
|
|
</div> |
|
|
<div |
|
|
v-else-if="account.platform === 'openai'" |
|
|
class="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-100 bg-gradient-to-r from-gray-100 to-gray-100 px-2.5 py-1" |
|
|
> |
|
|
<div class="fa-openai" /> |
|
|
<span class="text-xs font-semibold text-gray-950">OpenAi</span> |
|
|
<span class="mx-1 h-4 w-px bg-gray-400" /> |
|
|
<span class="text-xs font-medium text-gray-950">{{ getOpenAIAuthType() }}</span> |
|
|
</div> |
|
|
<div |
|
|
v-else-if="account.platform === 'azure_openai'" |
|
|
class="flex items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-100 to-cyan-100 px-2.5 py-1 dark:border-blue-700 dark:from-blue-900/20 dark:to-cyan-900/20" |
|
|
> |
|
|
<i class="fab fa-microsoft text-xs text-blue-700 dark:text-blue-400" /> |
|
|
<span class="text-xs font-semibold text-blue-800 dark:text-blue-300" |
|
|
>Azure OpenAI</span |
|
|
> |
|
|
<span class="mx-1 h-4 w-px bg-blue-300 dark:bg-blue-600" /> |
|
|
<span class="text-xs font-medium text-blue-700 dark:text-blue-400" |
|
|
>API Key</span |
|
|
> |
|
|
</div> |
|
|
<div |
|
|
v-else-if="account.platform === 'openai-responses'" |
|
|
class="flex items-center gap-1.5 rounded-lg border border-teal-200 bg-gradient-to-r from-teal-100 to-green-100 px-2.5 py-1 dark:border-teal-700 dark:from-teal-900/20 dark:to-green-900/20" |
|
|
> |
|
|
<i class="fas fa-server text-xs text-teal-700 dark:text-teal-400" /> |
|
|
<span class="text-xs font-semibold text-teal-800 dark:text-teal-300" |
|
|
>OpenAI-Responses</span |
|
|
> |
|
|
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" /> |
|
|
<span class="text-xs font-medium text-teal-700 dark:text-teal-400" |
|
|
>API Key</span |
|
|
> |
|
|
</div> |
|
|
<div |
|
|
v-else-if="account.platform === 'claude' || account.platform === 'claude-oauth'" |
|
|
class="flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100 px-2.5 py-1" |
|
|
> |
|
|
<i class="fas fa-brain text-xs text-indigo-700" /> |
|
|
<span class="text-xs font-semibold text-indigo-800">{{ |
|
|
getClaudeAccountType(account) |
|
|
}}</span> |
|
|
<span class="mx-1 h-4 w-px bg-indigo-300" /> |
|
|
<span class="text-xs font-medium text-indigo-700"> |
|
|
{{ getClaudeAuthType(account) }} |
|
|
</span> |
|
|
</div> |
|
|
<div |
|
|
v-else-if="account.platform === 'ccr'" |
|
|
class="flex items-center gap-1.5 rounded-lg border border-teal-200 bg-gradient-to-r from-teal-100 to-emerald-100 px-2.5 py-1 dark:border-teal-700 dark:from-teal-900/20 dark:to-emerald-900/20" |
|
|
> |
|
|
<i class="fas fa-code-branch text-xs text-teal-700 dark:text-teal-400" /> |
|
|
<span class="text-xs font-semibold text-teal-800 dark:text-teal-300">CCR</span> |
|
|
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" /> |
|
|
<span class="text-xs font-medium text-teal-700 dark:text-teal-300">Relay</span> |
|
|
</div> |
|
|
<div |
|
|
v-else-if="account.platform === 'droid'" |
|
|
class="flex items-center gap-1.5 rounded-lg border border-cyan-200 bg-gradient-to-r from-cyan-100 to-sky-100 px-2.5 py-1 dark:border-cyan-700 dark:from-cyan-900/20 dark:to-sky-900/20" |
|
|
> |
|
|
<i class="fas fa-robot text-xs text-cyan-700 dark:text-cyan-400" /> |
|
|
<span class="text-xs font-semibold text-cyan-800 dark:text-cyan-300" |
|
|
>Droid</span |
|
|
> |
|
|
<span class="mx-1 h-4 w-px bg-cyan-300 dark:bg-cyan-600" /> |
|
|
<span class="text-xs font-medium text-cyan-700 dark:text-cyan-300"> |
|
|
{{ getDroidAuthType(account) }} |
|
|
</span> |
|
|
<span |
|
|
v-if="isDroidApiKeyMode(account)" |
|
|
:class="getDroidApiKeyBadgeClasses(account)" |
|
|
> |
|
|
<i class="fas fa-key text-[9px]" /> |
|
|
<span>x{{ getDroidApiKeyCount(account) }}</span> |
|
|
</span> |
|
|
</div> |
|
|
<div |
|
|
v-else |
|
|
class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1" |
|
|
> |
|
|
<i class="fas fa-question text-xs text-gray-700" /> |
|
|
<span class="text-xs font-semibold text-gray-800">未知</span> |
|
|
</div> |
|
|
</div> |
|
|
</td> |
|
|
<td class="whitespace-nowrap px-3 py-4"> |
|
|
<div class="flex flex-col gap-1"> |
|
|
|
|
|
<span v-if="account.expiresAt"> |
|
|
<span |
|
|
v-if="isExpired(account.expiresAt)" |
|
|
class="inline-flex cursor-pointer items-center text-red-600 hover:underline" |
|
|
style="font-size: 13px" |
|
|
@click.stop="startEditAccountExpiry(account)" |
|
|
> |
|
|
<i class="fas fa-exclamation-circle mr-1 text-xs" /> |
|
|
已过期 |
|
|
</span> |
|
|
<span |
|
|
v-else-if="isExpiringSoon(account.expiresAt)" |
|
|
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline" |
|
|
style="font-size: 13px" |
|
|
@click.stop="startEditAccountExpiry(account)" |
|
|
> |
|
|
<i class="fas fa-clock mr-1 text-xs" /> |
|
|
{{ formatExpireDate(account.expiresAt) }} |
|
|
</span> |
|
|
<span |
|
|
v-else |
|
|
class="cursor-pointer text-gray-600 hover:underline dark:text-gray-400" |
|
|
style="font-size: 13px" |
|
|
@click.stop="startEditAccountExpiry(account)" |
|
|
> |
|
|
{{ formatExpireDate(account.expiresAt) }} |
|
|
</span> |
|
|
</span> |
|
|
|
|
|
<span |
|
|
v-else |
|
|
class="inline-flex cursor-pointer items-center text-gray-400 hover:underline dark:text-gray-500" |
|
|
style="font-size: 13px" |
|
|
@click.stop="startEditAccountExpiry(account)" |
|
|
> |
|
|
<i class="fas fa-infinity mr-1 text-xs" /> |
|
|
永不过期 |
|
|
</span> |
|
|
</div> |
|
|
</td> |
|
|
<td class="whitespace-nowrap px-3 py-4"> |
|
|
<div class="flex flex-col gap-1"> |
|
|
<span |
|
|
:class="[ |
|
|
'inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold', |
|
|
account.status === 'blocked' |
|
|
? 'bg-orange-100 text-orange-800' |
|
|
: account.status === 'unauthorized' |
|
|
? 'bg-red-100 text-red-800' |
|
|
: account.status === 'temp_error' |
|
|
? 'bg-orange-100 text-orange-800' |
|
|
: account.isActive |
|
|
? 'bg-green-100 text-green-800' |
|
|
: 'bg-red-100 text-red-800' |
|
|
]" |
|
|
> |
|
|
<div |
|
|
:class="[ |
|
|
'mr-2 h-2 w-2 rounded-full', |
|
|
account.status === 'blocked' |
|
|
? 'bg-orange-500' |
|
|
: account.status === 'unauthorized' |
|
|
? 'bg-red-500' |
|
|
: account.status === 'temp_error' |
|
|
? 'bg-orange-500' |
|
|
: account.isActive |
|
|
? 'bg-green-500' |
|
|
: 'bg-red-500' |
|
|
]" |
|
|
/> |
|
|
{{ |
|
|
account.status === 'blocked' |
|
|
? '已封锁' |
|
|
: account.status === 'unauthorized' |
|
|
? '异常' |
|
|
: account.status === 'temp_error' |
|
|
? '临时异常' |
|
|
: account.isActive |
|
|
? '正常' |
|
|
: '异常' |
|
|
}} |
|
|
</span> |
|
|
<span |
|
|
v-if=" |
|
|
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) || |
|
|
account.rateLimitStatus === 'limited' |
|
|
" |
|
|
class="inline-flex items-center rounded-full bg-yellow-100 px-3 py-1 text-xs font-semibold text-yellow-800" |
|
|
> |
|
|
<i class="fas fa-exclamation-triangle mr-1" /> |
|
|
限流中 |
|
|
<span |
|
|
v-if=" |
|
|
account.rateLimitStatus && |
|
|
typeof account.rateLimitStatus === 'object' && |
|
|
account.rateLimitStatus.minutesRemaining > 0 |
|
|
" |
|
|
>({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</span |
|
|
> |
|
|
</span> |
|
|
<span |
|
|
v-if="account.schedulable === false" |
|
|
class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700" |
|
|
> |
|
|
<i class="fas fa-pause-circle mr-1" /> |
|
|
不可调度 |
|
|
<el-tooltip |
|
|
v-if="getSchedulableReason(account)" |
|
|
:content="getSchedulableReason(account)" |
|
|
effect="dark" |
|
|
placement="top" |
|
|
> |
|
|
<i class="fas fa-question-circle ml-1 cursor-help text-gray-500" /> |
|
|
</el-tooltip> |
|
|
</span> |
|
|
<span |
|
|
v-if="account.status === 'blocked' && account.errorMessage" |
|
|
class="mt-1 max-w-xs truncate text-xs text-gray-500 dark:text-gray-400" |
|
|
:title="account.errorMessage" |
|
|
> |
|
|
{{ account.errorMessage }} |
|
|
</span> |
|
|
<span |
|
|
v-if="account.accountType === 'dedicated'" |
|
|
class="text-xs text-gray-500 dark:text-gray-400" |
|
|
> |
|
|
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key |
|
|
</span> |
|
|
</div> |
|
|
</td> |
|
|
<td class="whitespace-nowrap px-3 py-4"> |
|
|
<div |
|
|
v-if=" |
|
|
account.platform === 'claude' || |
|
|
account.platform === 'claude-console' || |
|
|
account.platform === 'bedrock' || |
|
|
account.platform === 'gemini' || |
|
|
account.platform === 'openai' || |
|
|
account.platform === 'openai-responses' || |
|
|
account.platform === 'azure_openai' || |
|
|
account.platform === 'ccr' || |
|
|
account.platform === 'droid' |
|
|
" |
|
|
class="flex items-center gap-2" |
|
|
> |
|
|
<div class="h-2 w-16 rounded-full bg-gray-200"> |
|
|
<div |
|
|
class="h-2 rounded-full bg-gradient-to-r from-green-500 to-blue-600 transition-all duration-300" |
|
|
:style="{ width: 101 - (account.priority || 50) + '%' }" |
|
|
/> |
|
|
</div> |
|
|
<span class="min-w-[20px] text-xs font-medium text-gray-700 dark:text-gray-200"> |
|
|
{{ account.priority || 50 }} |
|
|
</span> |
|
|
</div> |
|
|
<div v-else class="text-sm text-gray-400"> |
|
|
<span class="text-xs">N/A</span> |
|
|
</div> |
|
|
</td> |
|
|
<td class="px-3 py-4 text-sm text-gray-600"> |
|
|
<div |
|
|
v-if="formatProxyDisplay(account.proxy)" |
|
|
class="break-all rounded bg-blue-50 px-2 py-1 font-mono text-xs" |
|
|
:title="formatProxyDisplay(account.proxy)" |
|
|
> |
|
|
{{ formatProxyDisplay(account.proxy) }} |
|
|
</div> |
|
|
<div v-else class="text-gray-400">无代理</div> |
|
|
</td> |
|
|
<td class="whitespace-nowrap px-3 py-4 text-sm"> |
|
|
<div v-if="account.usage && account.usage.daily" class="space-y-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 w-2 rounded-full bg-blue-500" /> |
|
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100" |
|
|
>{{ account.usage.daily.requests || 0 }} 次</span |
|
|
> |
|
|
</div> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 w-2 rounded-full bg-purple-500" /> |
|
|
<span class="text-xs text-gray-600 dark:text-gray-300" |
|
|
>{{ formatNumber(account.usage.daily.allTokens || 0) }}M</span |
|
|
> |
|
|
</div> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 w-2 rounded-full bg-green-500" /> |
|
|
<span class="text-xs text-gray-600 dark:text-gray-300" |
|
|
>${{ calculateDailyCost(account) }}</span |
|
|
> |
|
|
</div> |
|
|
<div |
|
|
v-if="account.usage.averages && account.usage.averages.rpm > 0" |
|
|
class="text-xs text-gray-500 dark:text-gray-400" |
|
|
> |
|
|
平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM |
|
|
</div> |
|
|
</div> |
|
|
<div v-else class="text-xs text-gray-400">暂无数据</div> |
|
|
</td> |
|
|
<td class="whitespace-nowrap px-3 py-4"> |
|
|
<div v-if="account.platform === 'claude'" class="space-y-2"> |
|
|
|
|
|
<div v-if="isClaudeOAuth(account) && account.claudeUsage" class="space-y-2"> |
|
|
|
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300" |
|
|
> |
|
|
5h |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getClaudeUsageBarClass(account.claudeUsage.fiveHour) |
|
|
]" |
|
|
:style="{ |
|
|
width: getClaudeUsageWidth(account.claudeUsage.fiveHour) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatClaudeUsagePercent(account.claudeUsage.fiveHour) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.fiveHour) }} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300" |
|
|
> |
|
|
7d |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getClaudeUsageBarClass(account.claudeUsage.sevenDay) |
|
|
]" |
|
|
:style="{ |
|
|
width: getClaudeUsageWidth(account.claudeUsage.sevenDay) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDay) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDay) }} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-purple-100 px-2 py-0.5 text-[11px] font-medium text-purple-600 dark:bg-purple-500/20 dark:text-purple-300" |
|
|
> |
|
|
Opus |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getClaudeUsageBarClass(account.claudeUsage.sevenDayOpus) |
|
|
]" |
|
|
:style="{ |
|
|
width: getClaudeUsageWidth(account.claudeUsage.sevenDayOpus) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDayOpus) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDayOpus) }} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-else-if=" |
|
|
!isClaudeOAuth(account) && |
|
|
account.sessionWindow && |
|
|
account.sessionWindow.hasActiveWindow |
|
|
" |
|
|
class="space-y-2" |
|
|
> |
|
|
|
|
|
<div |
|
|
v-if="account.usage && account.usage.sessionWindow" |
|
|
class="flex items-center gap-3 text-xs" |
|
|
> |
|
|
<div class="flex items-center gap-1"> |
|
|
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" /> |
|
|
<span class="font-medium text-gray-900 dark:text-gray-100"> |
|
|
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M |
|
|
</span> |
|
|
</div> |
|
|
<div class="flex items-center gap-1"> |
|
|
<div class="h-1.5 w-1.5 rounded-full bg-green-500" /> |
|
|
<span class="font-medium text-gray-900 dark:text-gray-100"> |
|
|
${{ formatCost(account.usage.sessionWindow.totalCost) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getSessionProgressBarClass( |
|
|
account.sessionWindow.sessionWindowStatus, |
|
|
account |
|
|
) |
|
|
]" |
|
|
:style="{ width: account.sessionWindow.progress + '%' }" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200" |
|
|
> |
|
|
{{ account.sessionWindow.progress }}% |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="text-xs text-gray-600 dark:text-gray-400"> |
|
|
<div> |
|
|
{{ |
|
|
formatSessionWindow( |
|
|
account.sessionWindow.windowStart, |
|
|
account.sessionWindow.windowEnd |
|
|
) |
|
|
}} |
|
|
</div> |
|
|
<div |
|
|
v-if="account.sessionWindow.remainingTime > 0" |
|
|
class="font-medium text-indigo-600 dark:text-indigo-400" |
|
|
> |
|
|
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div v-else class="text-xs text-gray-400">暂无统计</div> |
|
|
</div> |
|
|
|
|
|
<div v-else-if="account.platform === 'claude-console'" class="space-y-2"> |
|
|
<div v-if="Number(account.dailyQuota) > 0"> |
|
|
<div class="flex items-center justify-between text-xs"> |
|
|
<span class="text-gray-600 dark:text-gray-300">额度进度</span> |
|
|
<span class="font-medium text-gray-700 dark:text-gray-200"> |
|
|
{{ getQuotaUsagePercent(account).toFixed(1) }}% |
|
|
</span> |
|
|
</div> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getQuotaBarClass(getQuotaUsagePercent(account)) |
|
|
]" |
|
|
:style="{ width: Math.min(100, getQuotaUsagePercent(account)) + '%' }" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200" |
|
|
> |
|
|
${{ formatCost(account.usage?.daily?.cost || 0) }} / ${{ |
|
|
Number(account.dailyQuota).toFixed(2) |
|
|
}} |
|
|
</span> |
|
|
</div> |
|
|
<div class="text-xs text-gray-600 dark:text-gray-400"> |
|
|
剩余 ${{ formatRemainingQuota(account) }} |
|
|
<span class="ml-2 text-gray-400" |
|
|
>重置 {{ account.quotaResetTime || '00:00' }}</span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
<div v-else class="text-sm text-gray-400"> |
|
|
<i class="fas fa-minus" /> |
|
|
</div> |
|
|
</div> |
|
|
<div v-else-if="account.platform === 'openai'" class="space-y-2"> |
|
|
<div v-if="account.codexUsage" class="space-y-2"> |
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300" |
|
|
> |
|
|
{{ getCodexWindowLabel('primary') }} |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getCodexUsageBarClass(account.codexUsage.primary) |
|
|
]" |
|
|
:style="{ |
|
|
width: getCodexUsageWidth(account.codexUsage.primary) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatCodexUsagePercent(account.codexUsage.primary) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }} |
|
|
</div> |
|
|
</div> |
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-600 dark:bg-blue-500/20 dark:text-blue-300" |
|
|
> |
|
|
{{ getCodexWindowLabel('secondary') }} |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getCodexUsageBarClass(account.codexUsage.secondary) |
|
|
]" |
|
|
:style="{ |
|
|
width: getCodexUsageWidth(account.codexUsage.secondary) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatCodexUsagePercent(account.codexUsage.secondary) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div v-else class="text-sm text-gray-400"> |
|
|
<span class="text-xs">N/A</span> |
|
|
</div> |
|
|
</div> |
|
|
<div v-else class="text-sm text-gray-400"> |
|
|
<span class="text-xs">N/A</span> |
|
|
</div> |
|
|
</td> |
|
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-300"> |
|
|
{{ formatLastUsed(account.lastUsedAt) }} |
|
|
</td> |
|
|
<td class="whitespace-nowrap px-3 py-4 text-sm font-medium"> |
|
|
<div class="flex flex-wrap items-center gap-1"> |
|
|
<button |
|
|
v-if=" |
|
|
(account.platform === 'claude' || |
|
|
account.platform === 'claude-console' || |
|
|
account.platform === 'openai' || |
|
|
account.platform === 'openai-responses') && |
|
|
(account.status === 'unauthorized' || |
|
|
account.status !== 'active' || |
|
|
account.rateLimitStatus?.isRateLimited || |
|
|
account.rateLimitStatus === 'limited' || |
|
|
!account.isActive) |
|
|
" |
|
|
:class="[ |
|
|
'rounded px-2.5 py-1 text-xs font-medium transition-colors', |
|
|
account.isResetting |
|
|
? 'cursor-not-allowed bg-gray-100 text-gray-400' |
|
|
: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200' |
|
|
]" |
|
|
:disabled="account.isResetting" |
|
|
:title="account.isResetting ? '重置中...' : '重置所有异常状态'" |
|
|
@click="resetAccountStatus(account)" |
|
|
> |
|
|
<i :class="['fas fa-redo', account.isResetting ? 'animate-spin' : '']" /> |
|
|
<span class="ml-1">重置状态</span> |
|
|
</button> |
|
|
<button |
|
|
:class="[ |
|
|
'rounded px-2.5 py-1 text-xs font-medium transition-colors', |
|
|
account.isTogglingSchedulable |
|
|
? 'cursor-not-allowed bg-gray-100 text-gray-400' |
|
|
: account.schedulable |
|
|
? 'bg-green-100 text-green-700 hover:bg-green-200' |
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' |
|
|
]" |
|
|
:disabled="account.isTogglingSchedulable" |
|
|
:title="account.schedulable ? '点击禁用调度' : '点击启用调度'" |
|
|
@click="toggleSchedulable(account)" |
|
|
> |
|
|
<i :class="['fas', account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off']" /> |
|
|
<span class="ml-1">{{ account.schedulable ? '调度' : '停用' }}</span> |
|
|
</button> |
|
|
<button |
|
|
v-if="canViewUsage(account)" |
|
|
class="rounded bg-indigo-100 px-2.5 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-200" |
|
|
:title="'查看使用详情'" |
|
|
@click="openAccountUsageModal(account)" |
|
|
> |
|
|
<i class="fas fa-chart-line" /> |
|
|
<span class="ml-1">详情</span> |
|
|
</button> |
|
|
<button |
|
|
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200" |
|
|
:title="'编辑账户'" |
|
|
@click="editAccount(account)" |
|
|
> |
|
|
<i class="fas fa-edit" /> |
|
|
<span class="ml-1">编辑</span> |
|
|
</button> |
|
|
<button |
|
|
class="rounded bg-red-100 px-2.5 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-200" |
|
|
:title="'删除账户'" |
|
|
@click="deleteAccount(account)" |
|
|
> |
|
|
<i class="fas fa-trash" /> |
|
|
<span class="ml-1">删除</span> |
|
|
</button> |
|
|
</div> |
|
|
</td> |
|
|
</tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="!accountsLoading && sortedAccounts.length > 0" class="space-y-3 md:hidden"> |
|
|
<div |
|
|
v-for="account in paginatedAccounts" |
|
|
:key="account.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="selectedAccounts" |
|
|
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" |
|
|
type="checkbox" |
|
|
:value="account.id" |
|
|
@change="updateSelectAllState" |
|
|
/> |
|
|
<div |
|
|
:class="[ |
|
|
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg', |
|
|
account.platform === 'claude' |
|
|
? 'bg-gradient-to-br from-purple-500 to-purple-600' |
|
|
: account.platform === 'bedrock' |
|
|
? 'bg-gradient-to-br from-orange-500 to-red-600' |
|
|
: account.platform === 'azure_openai' |
|
|
? 'bg-gradient-to-br from-blue-500 to-cyan-600' |
|
|
: account.platform === 'openai' |
|
|
? 'bg-gradient-to-br from-gray-600 to-gray-700' |
|
|
: account.platform === 'ccr' |
|
|
? 'bg-gradient-to-br from-teal-500 to-emerald-600' |
|
|
: account.platform === 'droid' |
|
|
? 'bg-gradient-to-br from-cyan-500 to-sky-600' |
|
|
: 'bg-gradient-to-br from-blue-500 to-blue-600' |
|
|
]" |
|
|
> |
|
|
<i |
|
|
:class="[ |
|
|
'text-sm text-white', |
|
|
account.platform === 'claude' |
|
|
? 'fas fa-brain' |
|
|
: account.platform === 'bedrock' |
|
|
? 'fab fa-aws' |
|
|
: account.platform === 'azure_openai' |
|
|
? 'fab fa-microsoft' |
|
|
: account.platform === 'openai' |
|
|
? 'fas fa-openai' |
|
|
: account.platform === 'ccr' |
|
|
? 'fas fa-code-branch' |
|
|
: account.platform === 'droid' |
|
|
? 'fas fa-robot' |
|
|
: 'fas fa-robot' |
|
|
]" |
|
|
/> |
|
|
</div> |
|
|
<div> |
|
|
<h4 class="text-sm font-semibold text-gray-900"> |
|
|
{{ account.name || account.email }} |
|
|
</h4> |
|
|
<div class="mt-0.5 flex items-center gap-2"> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ |
|
|
account.platform |
|
|
}}</span> |
|
|
<span class="text-xs text-gray-400">|</span> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ account.type }}</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<span |
|
|
:class="[ |
|
|
'inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold', |
|
|
getAccountStatusClass(account) |
|
|
]" |
|
|
> |
|
|
<div |
|
|
:class="['mr-1.5 h-1.5 w-1.5 rounded-full', getAccountStatusDotClass(account)]" |
|
|
/> |
|
|
{{ getAccountStatusText(account) }} |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-3 grid grid-cols-2 gap-3"> |
|
|
<div> |
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p> |
|
|
<div class="space-y-1"> |
|
|
<div class="flex items-center gap-1.5"> |
|
|
<div class="h-1.5 w-1.5 rounded-full bg-blue-500" /> |
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100"> |
|
|
{{ account.usage?.daily?.requests || 0 }} 次 |
|
|
</p> |
|
|
</div> |
|
|
<div class="flex items-center gap-1.5"> |
|
|
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" /> |
|
|
<p class="text-xs text-gray-600 dark:text-gray-400"> |
|
|
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}M |
|
|
</p> |
|
|
</div> |
|
|
<div class="flex items-center gap-1.5"> |
|
|
<div class="h-1.5 w-1.5 rounded-full bg-green-500" /> |
|
|
<p class="text-xs text-gray-600 dark:text-gray-400"> |
|
|
${{ calculateDailyCost(account) }} |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div> |
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">会话窗口</p> |
|
|
<div v-if="account.usage && account.usage.sessionWindow" class="space-y-1"> |
|
|
<div class="flex items-center gap-1.5"> |
|
|
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" /> |
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100"> |
|
|
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M |
|
|
</p> |
|
|
</div> |
|
|
<div class="flex items-center gap-1.5"> |
|
|
<div class="h-1.5 w-1.5 rounded-full bg-green-500" /> |
|
|
<p class="text-xs text-gray-600 dark:text-gray-400"> |
|
|
${{ formatCost(account.usage.sessionWindow.totalCost) }} |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
<div v-else class="text-sm font-semibold text-gray-400">-</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-3 space-y-2"> |
|
|
|
|
|
<div v-if="account.platform === 'claude'" class="space-y-2"> |
|
|
|
|
|
<div v-if="isClaudeOAuth(account) && account.claudeUsage" class="space-y-2"> |
|
|
|
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300" |
|
|
> |
|
|
5h |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getClaudeUsageBarClass(account.claudeUsage.fiveHour) |
|
|
]" |
|
|
:style="{ |
|
|
width: getClaudeUsageWidth(account.claudeUsage.fiveHour) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatClaudeUsagePercent(account.claudeUsage.fiveHour) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.fiveHour) }} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300" |
|
|
> |
|
|
7d |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getClaudeUsageBarClass(account.claudeUsage.sevenDay) |
|
|
]" |
|
|
:style="{ |
|
|
width: getClaudeUsageWidth(account.claudeUsage.sevenDay) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDay) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDay) }} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-purple-100 px-2 py-0.5 text-[11px] font-medium text-purple-600 dark:bg-purple-500/20 dark:text-purple-300" |
|
|
> |
|
|
Opus |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getClaudeUsageBarClass(account.claudeUsage.sevenDayOpus) |
|
|
]" |
|
|
:style="{ |
|
|
width: getClaudeUsageWidth(account.claudeUsage.sevenDayOpus) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDayOpus) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDayOpus) }} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-else-if=" |
|
|
!isClaudeOAuth(account) && |
|
|
account.sessionWindow && |
|
|
account.sessionWindow.hasActiveWindow |
|
|
" |
|
|
class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700" |
|
|
> |
|
|
<div class="flex items-center justify-between text-xs"> |
|
|
<div class="flex items-center gap-1"> |
|
|
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span> |
|
|
<el-tooltip |
|
|
content="会话窗口进度不代表使用量,仅表示距离下一个5小时窗口的剩余时间" |
|
|
placement="top" |
|
|
> |
|
|
<i |
|
|
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600" |
|
|
/> |
|
|
</el-tooltip> |
|
|
</div> |
|
|
<span class="font-medium text-gray-700 dark:text-gray-200"> |
|
|
{{ account.sessionWindow.progress }}% |
|
|
</span> |
|
|
</div> |
|
|
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-full transition-all duration-300', |
|
|
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus, account) |
|
|
]" |
|
|
:style="{ width: account.sessionWindow.progress + '%' }" |
|
|
/> |
|
|
</div> |
|
|
<div class="flex items-center justify-between text-xs"> |
|
|
<span class="text-gray-500 dark:text-gray-400"> |
|
|
{{ |
|
|
formatSessionWindow( |
|
|
account.sessionWindow.windowStart, |
|
|
account.sessionWindow.windowEnd |
|
|
) |
|
|
}} |
|
|
</span> |
|
|
<span |
|
|
v-if="account.sessionWindow.remainingTime > 0" |
|
|
class="font-medium text-indigo-600" |
|
|
> |
|
|
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }} |
|
|
</span> |
|
|
<span v-else class="text-gray-500"> 已结束 </span> |
|
|
</div> |
|
|
</div> |
|
|
<div v-else class="text-xs text-gray-400">暂无统计</div> |
|
|
</div> |
|
|
<div v-else-if="account.platform === 'openai'" class="space-y-2"> |
|
|
<div v-if="account.codexUsage" class="space-y-2"> |
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300" |
|
|
> |
|
|
{{ getCodexWindowLabel('primary') }} |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getCodexUsageBarClass(account.codexUsage.primary) |
|
|
]" |
|
|
:style="{ |
|
|
width: getCodexUsageWidth(account.codexUsage.primary) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatCodexUsagePercent(account.codexUsage.primary) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }} |
|
|
</div> |
|
|
</div> |
|
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<span |
|
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-600 dark:bg-blue-500/20 dark:text-blue-300" |
|
|
> |
|
|
{{ getCodexWindowLabel('secondary') }} |
|
|
</span> |
|
|
<div class="flex-1"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600"> |
|
|
<div |
|
|
:class="[ |
|
|
'h-2 rounded-full transition-all duration-300', |
|
|
getCodexUsageBarClass(account.codexUsage.secondary) |
|
|
]" |
|
|
:style="{ |
|
|
width: getCodexUsageWidth(account.codexUsage.secondary) |
|
|
}" |
|
|
/> |
|
|
</div> |
|
|
<span |
|
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100" |
|
|
> |
|
|
{{ formatCodexUsagePercent(account.codexUsage.secondary) }} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
|
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div v-if="!account.codexUsage" class="text-xs text-gray-400">暂无统计</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<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-200"> |
|
|
{{ account.lastUsedAt ? formatRelativeTime(account.lastUsedAt) : '从未使用' }} |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
v-if="account.proxyConfig && account.proxyConfig.type !== 'none'" |
|
|
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-200"> |
|
|
{{ account.proxyConfig.type.toUpperCase() }} |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="flex items-center justify-between text-xs"> |
|
|
<span class="text-gray-500 dark:text-gray-400">优先级</span> |
|
|
<span class="font-medium text-gray-700 dark:text-gray-200"> |
|
|
{{ account.priority || 50 }} |
|
|
</span> |
|
|
</div> |
|
|
</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 px-3 py-2 text-xs transition-colors" |
|
|
:class=" |
|
|
account.schedulable |
|
|
? 'bg-gray-50 text-gray-600 hover:bg-gray-100' |
|
|
: 'bg-green-50 text-green-600 hover:bg-green-100' |
|
|
" |
|
|
:disabled="account.isTogglingSchedulable" |
|
|
@click="toggleSchedulable(account)" |
|
|
> |
|
|
<i :class="['fas', account.schedulable ? 'fa-pause' : 'fa-play']" /> |
|
|
{{ account.schedulable ? '暂停' : '启用' }} |
|
|
</button> |
|
|
|
|
|
<button |
|
|
v-if="canViewUsage(account)" |
|
|
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-indigo-50 px-3 py-2 text-xs text-indigo-600 transition-colors hover:bg-indigo-100" |
|
|
@click="openAccountUsageModal(account)" |
|
|
> |
|
|
<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="editAccount(account)" |
|
|
> |
|
|
<i class="fas fa-edit 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="deleteAccount(account)" |
|
|
> |
|
|
<i class="fas fa-trash" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if="!accountsLoading && sortedAccounts.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"> |
|
|
共 {{ sortedAccounts.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="shouldShowFirstPage" |
|
|
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="showLeadingEllipsis" |
|
|
class="hidden px-2 text-sm text-gray-500 dark:text-gray-400 sm:block" |
|
|
> |
|
|
... |
|
|
</span> |
|
|
|
|
|
<button |
|
|
v-for="page in pageNumbers" |
|
|
:key="page" |
|
|
:class="[ |
|
|
'rounded-md border px-3 py-1 text-xs font-medium transition-colors sm:text-sm', |
|
|
page === currentPage |
|
|
? 'border-blue-500 bg-blue-50 text-blue-600 dark:border-blue-400 dark:bg-blue-500/10 dark:text-blue-300' |
|
|
: '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="showTrailingEllipsis" |
|
|
class="hidden px-2 text-sm text-gray-500 dark:text-gray-400 sm:block" |
|
|
> |
|
|
... |
|
|
</span> |
|
|
|
|
|
<button |
|
|
v-if="shouldShowLastPage" |
|
|
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> |
|
|
|
|
|
|
|
|
<AccountForm |
|
|
v-if="showCreateAccountModal && (!newAccountPlatform || newAccountPlatform !== 'ccr')" |
|
|
@close="closeCreateAccountModal" |
|
|
@platform-changed="newAccountPlatform = $event" |
|
|
@success="handleCreateSuccess" |
|
|
/> |
|
|
<CcrAccountForm |
|
|
v-else-if="showCreateAccountModal && newAccountPlatform === 'ccr'" |
|
|
@close="closeCreateAccountModal" |
|
|
@success="handleCreateSuccess" |
|
|
/> |
|
|
|
|
|
|
|
|
<CcrAccountForm |
|
|
v-if="showEditAccountModal && editingAccount && editingAccount.platform === 'ccr'" |
|
|
:account="editingAccount" |
|
|
@close="showEditAccountModal = false" |
|
|
@success="handleEditSuccess" |
|
|
/> |
|
|
<AccountForm |
|
|
v-else-if="showEditAccountModal" |
|
|
:account="editingAccount" |
|
|
@close="showEditAccountModal = false" |
|
|
@success="handleEditSuccess" |
|
|
/> |
|
|
|
|
|
|
|
|
<ConfirmModal |
|
|
:cancel-text="confirmOptions.cancelText" |
|
|
:confirm-text="confirmOptions.confirmText" |
|
|
:message="confirmOptions.message" |
|
|
:show="showConfirmModal" |
|
|
:title="confirmOptions.title" |
|
|
@cancel="handleCancel" |
|
|
@confirm="handleConfirm" |
|
|
/> |
|
|
|
|
|
<AccountUsageDetailModal |
|
|
v-if="showAccountUsageModal" |
|
|
:account="selectedAccountForUsage || {}" |
|
|
:generated-at="accountUsageGeneratedAt" |
|
|
:history="accountUsageHistory" |
|
|
:loading="accountUsageLoading" |
|
|
:overview="accountUsageOverview" |
|
|
:show="showAccountUsageModal" |
|
|
:summary="accountUsageSummary" |
|
|
@close="closeAccountUsageModal" |
|
|
/> |
|
|
|
|
|
|
|
|
<AccountExpiryEditModal |
|
|
ref="expiryEditModalRef" |
|
|
:account="editingExpiryAccount || { id: null, expiresAt: null, name: '' }" |
|
|
:show="!!editingExpiryAccount" |
|
|
@close="closeAccountExpiryEdit" |
|
|
@save="handleSaveAccountExpiry" |
|
|
/> |
|
|
</div> |
|
|
</template> |
|
|
|
|
|
<script setup> |
|
|
import { ref, computed, onMounted, watch } from 'vue' |
|
|
import { showToast } from '@/utils/toast' |
|
|
import { apiClient } from '@/config/api' |
|
|
import { useConfirm } from '@/composables/useConfirm' |
|
|
import AccountForm from '@/components/accounts/AccountForm.vue' |
|
|
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue' |
|
|
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue' |
|
|
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue' |
|
|
import ConfirmModal from '@/components/common/ConfirmModal.vue' |
|
|
import CustomDropdown from '@/components/common/CustomDropdown.vue' |
|
|
|
|
|
|
|
|
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm() |
|
|
|
|
|
|
|
|
const accounts = ref([]) |
|
|
const accountsLoading = ref(false) |
|
|
const accountSortBy = ref('name') |
|
|
const accountsSortBy = ref('') |
|
|
const accountsSortOrder = ref('asc') |
|
|
const apiKeys = ref([]) |
|
|
const accountGroups = ref([]) |
|
|
const groupFilter = ref('all') |
|
|
const platformFilter = ref('all') |
|
|
const searchKeyword = ref('') |
|
|
const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize' |
|
|
const getInitialPageSize = () => { |
|
|
const saved = localStorage.getItem(PAGE_SIZE_STORAGE_KEY) |
|
|
if (saved) { |
|
|
const parsedSize = parseInt(saved, 10) |
|
|
if ([10, 20, 50, 100].includes(parsedSize)) { |
|
|
return parsedSize |
|
|
} |
|
|
} |
|
|
return 10 |
|
|
} |
|
|
const pageSizeOptions = [10, 20, 50, 100] |
|
|
const pageSize = ref(getInitialPageSize()) |
|
|
const currentPage = ref(1) |
|
|
|
|
|
|
|
|
const selectedAccounts = ref([]) |
|
|
const selectAllChecked = ref(false) |
|
|
const isIndeterminate = ref(false) |
|
|
const showCheckboxes = ref(false) |
|
|
|
|
|
|
|
|
const showAccountUsageModal = ref(false) |
|
|
const accountUsageLoading = ref(false) |
|
|
const selectedAccountForUsage = ref(null) |
|
|
const accountUsageHistory = ref([]) |
|
|
const accountUsageSummary = ref({}) |
|
|
const accountUsageOverview = ref({}) |
|
|
const accountUsageGeneratedAt = ref('') |
|
|
|
|
|
const supportedUsagePlatforms = [ |
|
|
'claude', |
|
|
'claude-console', |
|
|
'openai', |
|
|
'openai-responses', |
|
|
'gemini', |
|
|
'droid' |
|
|
] |
|
|
|
|
|
|
|
|
const editingExpiryAccount = ref(null) |
|
|
const expiryEditModalRef = ref(null) |
|
|
|
|
|
|
|
|
const apiKeysLoaded = ref(false) |
|
|
const groupsLoaded = ref(false) |
|
|
const groupMembersLoaded = ref(false) |
|
|
const accountGroupMap = ref(new Map()) |
|
|
|
|
|
|
|
|
const sortOptions = ref([ |
|
|
{ value: 'name', label: '按名称排序', icon: 'fa-font' }, |
|
|
{ value: 'dailyTokens', label: '按今日Token排序', icon: 'fa-coins' }, |
|
|
{ value: 'dailyRequests', label: '按今日请求数排序', icon: 'fa-chart-line' }, |
|
|
{ value: 'totalTokens', label: '按总Token排序', icon: 'fa-database' }, |
|
|
{ value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' } |
|
|
]) |
|
|
|
|
|
const platformOptions = ref([ |
|
|
{ value: 'all', label: '所有平台', icon: 'fa-globe' }, |
|
|
{ value: 'claude', label: 'Claude', icon: 'fa-brain' }, |
|
|
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' }, |
|
|
{ value: 'gemini', label: 'Gemini', icon: 'fab fa-google' }, |
|
|
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' }, |
|
|
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' }, |
|
|
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }, |
|
|
{ value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' }, |
|
|
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' }, |
|
|
{ value: 'droid', label: 'Droid', icon: 'fa-robot' } |
|
|
]) |
|
|
|
|
|
const groupOptions = computed(() => { |
|
|
const options = [ |
|
|
{ value: 'all', label: '所有账户', icon: 'fa-globe' }, |
|
|
{ value: 'ungrouped', label: '未分组账户', icon: 'fa-user' } |
|
|
] |
|
|
accountGroups.value.forEach((group) => { |
|
|
options.push({ |
|
|
value: group.id, |
|
|
label: `${group.name} (${group.platform === 'claude' ? 'Claude' : group.platform === 'gemini' ? 'Gemini' : group.platform === 'openai' ? 'OpenAI' : 'Droid'})`, |
|
|
icon: |
|
|
group.platform === 'claude' |
|
|
? 'fa-brain' |
|
|
: group.platform === 'gemini' |
|
|
? 'fa-robot' |
|
|
: group.platform === 'openai' |
|
|
? 'fa-openai' |
|
|
: 'fa-robot' |
|
|
}) |
|
|
}) |
|
|
return options |
|
|
}) |
|
|
|
|
|
const shouldShowCheckboxes = computed(() => showCheckboxes.value) |
|
|
|
|
|
|
|
|
const showCreateAccountModal = ref(false) |
|
|
const newAccountPlatform = ref(null) |
|
|
const showEditAccountModal = ref(false) |
|
|
const editingAccount = ref(null) |
|
|
|
|
|
const collectAccountSearchableStrings = (account) => { |
|
|
const values = new Set() |
|
|
|
|
|
const baseFields = [ |
|
|
account?.name, |
|
|
account?.email, |
|
|
account?.accountName, |
|
|
account?.owner, |
|
|
account?.ownerName, |
|
|
account?.ownerDisplayName, |
|
|
account?.displayName, |
|
|
account?.username, |
|
|
account?.identifier, |
|
|
account?.alias, |
|
|
account?.title, |
|
|
account?.label |
|
|
] |
|
|
|
|
|
baseFields.forEach((field) => { |
|
|
if (typeof field === 'string') { |
|
|
const trimmed = field.trim() |
|
|
if (trimmed) { |
|
|
values.add(trimmed) |
|
|
} |
|
|
} |
|
|
}) |
|
|
|
|
|
if (Array.isArray(account?.groupInfos)) { |
|
|
account.groupInfos.forEach((group) => { |
|
|
if (group && typeof group.name === 'string') { |
|
|
const trimmed = group.name.trim() |
|
|
if (trimmed) { |
|
|
values.add(trimmed) |
|
|
} |
|
|
} |
|
|
}) |
|
|
} |
|
|
|
|
|
Object.entries(account || {}).forEach(([key, value]) => { |
|
|
if (typeof value === 'string') { |
|
|
const lowerKey = key.toLowerCase() |
|
|
if (lowerKey.includes('name') || lowerKey.includes('email')) { |
|
|
const trimmed = value.trim() |
|
|
if (trimmed) { |
|
|
values.add(trimmed) |
|
|
} |
|
|
} |
|
|
} |
|
|
}) |
|
|
|
|
|
return Array.from(values) |
|
|
} |
|
|
|
|
|
const accountMatchesKeyword = (account, normalizedKeyword) => { |
|
|
if (!normalizedKeyword) return true |
|
|
return collectAccountSearchableStrings(account).some((value) => |
|
|
value.toLowerCase().includes(normalizedKeyword) |
|
|
) |
|
|
} |
|
|
|
|
|
const canViewUsage = (account) => !!account && supportedUsagePlatforms.includes(account.platform) |
|
|
|
|
|
const openAccountUsageModal = async (account) => { |
|
|
if (!canViewUsage(account)) { |
|
|
showToast('该账户类型暂不支持查看详情', 'warning') |
|
|
return |
|
|
} |
|
|
|
|
|
selectedAccountForUsage.value = account |
|
|
showAccountUsageModal.value = true |
|
|
accountUsageLoading.value = true |
|
|
accountUsageHistory.value = [] |
|
|
accountUsageSummary.value = {} |
|
|
accountUsageOverview.value = {} |
|
|
accountUsageGeneratedAt.value = '' |
|
|
|
|
|
try { |
|
|
const response = await apiClient.get( |
|
|
`/admin/accounts/${account.id}/usage-history?platform=${account.platform}&days=30` |
|
|
) |
|
|
|
|
|
if (response.success) { |
|
|
const data = response.data || {} |
|
|
accountUsageHistory.value = data.history || [] |
|
|
accountUsageSummary.value = data.summary || {} |
|
|
accountUsageOverview.value = data.overview || {} |
|
|
accountUsageGeneratedAt.value = data.generatedAt || '' |
|
|
} else { |
|
|
showToast(response.error || '加载账号使用详情失败', 'error') |
|
|
} |
|
|
} catch (error) { |
|
|
showToast('加载账号使用详情失败', 'error') |
|
|
} finally { |
|
|
accountUsageLoading.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
const closeAccountUsageModal = () => { |
|
|
showAccountUsageModal.value = false |
|
|
accountUsageLoading.value = false |
|
|
selectedAccountForUsage.value = null |
|
|
} |
|
|
|
|
|
|
|
|
const sortedAccounts = computed(() => { |
|
|
let sourceAccounts = accounts.value |
|
|
|
|
|
const keyword = searchKeyword.value.trim() |
|
|
if (keyword) { |
|
|
const normalizedKeyword = keyword.toLowerCase() |
|
|
sourceAccounts = sourceAccounts.filter((account) => |
|
|
accountMatchesKeyword(account, normalizedKeyword) |
|
|
) |
|
|
} |
|
|
|
|
|
if (!accountsSortBy.value) return sourceAccounts |
|
|
|
|
|
const sorted = [...sourceAccounts].sort((a, b) => { |
|
|
let aVal = a[accountsSortBy.value] |
|
|
let bVal = b[accountsSortBy.value] |
|
|
|
|
|
|
|
|
if (accountsSortBy.value === 'dailyTokens') { |
|
|
aVal = a.usage?.daily?.allTokens || 0 |
|
|
bVal = b.usage?.daily?.allTokens || 0 |
|
|
} else if (accountsSortBy.value === 'dailyRequests') { |
|
|
aVal = a.usage?.daily?.requests || 0 |
|
|
bVal = b.usage?.daily?.requests || 0 |
|
|
} else if (accountsSortBy.value === 'totalTokens') { |
|
|
aVal = a.usage?.total?.allTokens || 0 |
|
|
bVal = b.usage?.total?.allTokens || 0 |
|
|
} |
|
|
|
|
|
|
|
|
if (accountsSortBy.value === 'lastUsed') { |
|
|
aVal = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0 |
|
|
bVal = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0 |
|
|
} |
|
|
|
|
|
|
|
|
if (accountsSortBy.value === 'status') { |
|
|
aVal = a.isActive ? 1 : 0 |
|
|
bVal = b.isActive ? 1 : 0 |
|
|
} |
|
|
|
|
|
if (aVal < bVal) return accountsSortOrder.value === 'asc' ? -1 : 1 |
|
|
if (aVal > bVal) return accountsSortOrder.value === 'asc' ? 1 : -1 |
|
|
return 0 |
|
|
}) |
|
|
|
|
|
return sorted |
|
|
}) |
|
|
|
|
|
const totalPages = computed(() => { |
|
|
const total = sortedAccounts.value.length |
|
|
return Math.ceil(total / pageSize.value) || 0 |
|
|
}) |
|
|
|
|
|
const pageNumbers = computed(() => { |
|
|
const total = totalPages.value |
|
|
const current = currentPage.value |
|
|
const pages = [] |
|
|
|
|
|
if (total <= 7) { |
|
|
for (let i = 1; i <= total; i++) { |
|
|
pages.push(i) |
|
|
} |
|
|
} else { |
|
|
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 shouldShowFirstPage = computed(() => { |
|
|
const pages = pageNumbers.value |
|
|
if (pages.length === 0) return false |
|
|
return pages[0] > 1 |
|
|
}) |
|
|
|
|
|
const shouldShowLastPage = computed(() => { |
|
|
const pages = pageNumbers.value |
|
|
if (pages.length === 0) return false |
|
|
return pages[pages.length - 1] < totalPages.value |
|
|
}) |
|
|
|
|
|
const showLeadingEllipsis = computed(() => { |
|
|
const pages = pageNumbers.value |
|
|
if (pages.length === 0) return false |
|
|
return shouldShowFirstPage.value && pages[0] > 2 |
|
|
}) |
|
|
|
|
|
const showTrailingEllipsis = computed(() => { |
|
|
const pages = pageNumbers.value |
|
|
if (pages.length === 0) return false |
|
|
return shouldShowLastPage.value && pages[pages.length - 1] < totalPages.value - 1 |
|
|
}) |
|
|
|
|
|
const paginatedAccounts = computed(() => { |
|
|
const start = (currentPage.value - 1) * pageSize.value |
|
|
const end = start + pageSize.value |
|
|
return sortedAccounts.value.slice(start, end) |
|
|
}) |
|
|
|
|
|
const updateSelectAllState = () => { |
|
|
const currentIds = paginatedAccounts.value.map((account) => account.id) |
|
|
const selectedInCurrentPage = currentIds.filter((id) => |
|
|
selectedAccounts.value.includes(id) |
|
|
).length |
|
|
const totalInCurrentPage = currentIds.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 handleSelectAll = () => { |
|
|
if (selectAllChecked.value) { |
|
|
paginatedAccounts.value.forEach((account) => { |
|
|
if (!selectedAccounts.value.includes(account.id)) { |
|
|
selectedAccounts.value.push(account.id) |
|
|
} |
|
|
}) |
|
|
} else { |
|
|
const currentIds = new Set(paginatedAccounts.value.map((account) => account.id)) |
|
|
selectedAccounts.value = selectedAccounts.value.filter((id) => !currentIds.has(id)) |
|
|
} |
|
|
updateSelectAllState() |
|
|
} |
|
|
|
|
|
const toggleSelectionMode = () => { |
|
|
showCheckboxes.value = !showCheckboxes.value |
|
|
if (!showCheckboxes.value) { |
|
|
selectedAccounts.value = [] |
|
|
selectAllChecked.value = false |
|
|
isIndeterminate.value = false |
|
|
} else { |
|
|
updateSelectAllState() |
|
|
} |
|
|
} |
|
|
|
|
|
const cleanupSelectedAccounts = () => { |
|
|
const validIds = new Set(accounts.value.map((account) => account.id)) |
|
|
selectedAccounts.value = selectedAccounts.value.filter((id) => validIds.has(id)) |
|
|
updateSelectAllState() |
|
|
} |
|
|
|
|
|
|
|
|
const loadAccounts = async (forceReload = false) => { |
|
|
accountsLoading.value = true |
|
|
try { |
|
|
|
|
|
const params = {} |
|
|
if (platformFilter.value !== 'all') { |
|
|
params.platform = platformFilter.value |
|
|
} |
|
|
if (groupFilter.value !== 'all') { |
|
|
params.groupId = groupFilter.value |
|
|
} |
|
|
|
|
|
|
|
|
const requests = [] |
|
|
|
|
|
if (platformFilter.value === 'all') { |
|
|
|
|
|
requests.push( |
|
|
apiClient.get('/admin/claude-accounts', { params }), |
|
|
apiClient.get('/admin/claude-console-accounts', { params }), |
|
|
apiClient.get('/admin/bedrock-accounts', { params }), |
|
|
apiClient.get('/admin/gemini-accounts', { params }), |
|
|
apiClient.get('/admin/openai-accounts', { params }), |
|
|
apiClient.get('/admin/azure-openai-accounts', { params }), |
|
|
apiClient.get('/admin/openai-responses-accounts', { params }), |
|
|
apiClient.get('/admin/ccr-accounts', { params }), |
|
|
apiClient.get('/admin/droid-accounts', { params }) |
|
|
) |
|
|
} else { |
|
|
|
|
|
switch (platformFilter.value) { |
|
|
case 'claude': |
|
|
requests.push( |
|
|
apiClient.get('/admin/claude-accounts', { params }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }) |
|
|
) |
|
|
break |
|
|
case 'claude-console': |
|
|
requests.push( |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
apiClient.get('/admin/claude-console-accounts', { params }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }) |
|
|
) |
|
|
break |
|
|
case 'bedrock': |
|
|
requests.push( |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
apiClient.get('/admin/bedrock-accounts', { params }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }) |
|
|
) |
|
|
break |
|
|
case 'gemini': |
|
|
requests.push( |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
apiClient.get('/admin/gemini-accounts', { params }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }) |
|
|
) |
|
|
break |
|
|
case 'openai': |
|
|
requests.push( |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
apiClient.get('/admin/openai-accounts', { params }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }) |
|
|
) |
|
|
break |
|
|
case 'azure_openai': |
|
|
requests.push( |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
apiClient.get('/admin/azure-openai-accounts', { params }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }) |
|
|
) |
|
|
break |
|
|
case 'openai-responses': |
|
|
requests.push( |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
apiClient.get('/admin/openai-responses-accounts', { params }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }) |
|
|
) |
|
|
break |
|
|
case 'ccr': |
|
|
requests.push( |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
apiClient.get('/admin/ccr-accounts', { params }), |
|
|
Promise.resolve({ success: true, data: [] }) |
|
|
) |
|
|
break |
|
|
case 'droid': |
|
|
requests.push( |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
apiClient.get('/admin/droid-accounts', { params }) |
|
|
) |
|
|
break |
|
|
default: |
|
|
|
|
|
requests.push( |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }), |
|
|
Promise.resolve({ success: true, data: [] }) |
|
|
) |
|
|
break |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
await Promise.all([loadApiKeys(forceReload), loadAccountGroups(forceReload)]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const [ |
|
|
claudeData, |
|
|
claudeConsoleData, |
|
|
bedrockData, |
|
|
geminiData, |
|
|
openaiData, |
|
|
azureOpenaiData, |
|
|
openaiResponsesData, |
|
|
ccrData, |
|
|
droidData |
|
|
] = await Promise.all(requests) |
|
|
|
|
|
const allAccounts = [] |
|
|
|
|
|
if (claudeData.success) { |
|
|
const claudeAccounts = (claudeData.data || []).map((acc) => { |
|
|
|
|
|
const boundApiKeysCount = apiKeys.value.filter( |
|
|
(key) => key.claudeAccountId === acc.id |
|
|
).length |
|
|
|
|
|
return { ...acc, platform: 'claude', boundApiKeysCount } |
|
|
}) |
|
|
allAccounts.push(...claudeAccounts) |
|
|
} |
|
|
|
|
|
if (claudeConsoleData.success) { |
|
|
const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => { |
|
|
|
|
|
const boundApiKeysCount = apiKeys.value.filter( |
|
|
(key) => key.claudeConsoleAccountId === acc.id |
|
|
).length |
|
|
|
|
|
return { ...acc, platform: 'claude-console', boundApiKeysCount } |
|
|
}) |
|
|
allAccounts.push(...claudeConsoleAccounts) |
|
|
} |
|
|
|
|
|
if (bedrockData.success) { |
|
|
const bedrockAccounts = (bedrockData.data || []).map((acc) => { |
|
|
|
|
|
|
|
|
return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 } |
|
|
}) |
|
|
allAccounts.push(...bedrockAccounts) |
|
|
} |
|
|
|
|
|
if (geminiData.success) { |
|
|
const geminiAccounts = (geminiData.data || []).map((acc) => { |
|
|
|
|
|
const boundApiKeysCount = apiKeys.value.filter( |
|
|
(key) => key.geminiAccountId === acc.id |
|
|
).length |
|
|
|
|
|
return { ...acc, platform: 'gemini', boundApiKeysCount } |
|
|
}) |
|
|
allAccounts.push(...geminiAccounts) |
|
|
} |
|
|
if (openaiData.success) { |
|
|
const openaiAccounts = (openaiData.data || []).map((acc) => { |
|
|
|
|
|
const boundApiKeysCount = apiKeys.value.filter( |
|
|
(key) => key.openaiAccountId === acc.id |
|
|
).length |
|
|
|
|
|
return { ...acc, platform: 'openai', boundApiKeysCount } |
|
|
}) |
|
|
allAccounts.push(...openaiAccounts) |
|
|
} |
|
|
if (azureOpenaiData && azureOpenaiData.success) { |
|
|
const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => { |
|
|
|
|
|
const boundApiKeysCount = apiKeys.value.filter( |
|
|
(key) => key.azureOpenaiAccountId === acc.id |
|
|
).length |
|
|
|
|
|
return { ...acc, platform: 'azure_openai', boundApiKeysCount } |
|
|
}) |
|
|
allAccounts.push(...azureOpenaiAccounts) |
|
|
} |
|
|
|
|
|
if (openaiResponsesData && openaiResponsesData.success) { |
|
|
const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => { |
|
|
|
|
|
|
|
|
const boundApiKeysCount = apiKeys.value.filter( |
|
|
(key) => key.openaiAccountId === `responses:${acc.id}` |
|
|
).length |
|
|
|
|
|
return { ...acc, platform: 'openai-responses', boundApiKeysCount } |
|
|
}) |
|
|
allAccounts.push(...openaiResponsesAccounts) |
|
|
} |
|
|
|
|
|
|
|
|
if (ccrData && ccrData.success) { |
|
|
const ccrAccounts = (ccrData.data || []).map((acc) => { |
|
|
|
|
|
return { ...acc, platform: 'ccr', boundApiKeysCount: 0 } |
|
|
}) |
|
|
allAccounts.push(...ccrAccounts) |
|
|
} |
|
|
|
|
|
|
|
|
if (droidData && droidData.success) { |
|
|
const droidAccounts = (droidData.data || []).map((acc) => { |
|
|
return { |
|
|
...acc, |
|
|
platform: 'droid', |
|
|
boundApiKeysCount: acc.boundApiKeysCount ?? 0 |
|
|
} |
|
|
}) |
|
|
allAccounts.push(...droidAccounts) |
|
|
} |
|
|
|
|
|
|
|
|
let filteredAccounts = allAccounts |
|
|
if (groupFilter.value !== 'all') { |
|
|
if (groupFilter.value === 'ungrouped') { |
|
|
|
|
|
filteredAccounts = allAccounts.filter((account) => { |
|
|
return !account.groupInfos || account.groupInfos.length === 0 |
|
|
}) |
|
|
} else { |
|
|
|
|
|
filteredAccounts = allAccounts.filter((account) => { |
|
|
if (!account.groupInfos || account.groupInfos.length === 0) { |
|
|
return false |
|
|
} |
|
|
|
|
|
return account.groupInfos.some((group) => group.id === groupFilter.value) |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
filteredAccounts = filteredAccounts.map((account) => { |
|
|
const proxyConfig = normalizeProxyData(account.proxyConfig || account.proxy) |
|
|
return { |
|
|
...account, |
|
|
proxyConfig: proxyConfig || null |
|
|
} |
|
|
}) |
|
|
|
|
|
accounts.value = filteredAccounts |
|
|
cleanupSelectedAccounts() |
|
|
|
|
|
|
|
|
if (filteredAccounts.some((acc) => acc.platform === 'claude')) { |
|
|
loadClaudeUsage().catch((err) => { |
|
|
console.debug('Claude usage loading failed:', err) |
|
|
}) |
|
|
} |
|
|
} catch (error) { |
|
|
showToast('加载账户失败', 'error') |
|
|
} finally { |
|
|
accountsLoading.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const loadClaudeUsage = async () => { |
|
|
try { |
|
|
const response = await apiClient.get('/admin/claude-accounts/usage') |
|
|
if (response.success && response.data) { |
|
|
const usageMap = response.data |
|
|
|
|
|
|
|
|
accounts.value = accounts.value.map((account) => { |
|
|
if (account.platform === 'claude' && usageMap[account.id]) { |
|
|
return { |
|
|
...account, |
|
|
claudeUsage: usageMap[account.id] |
|
|
} |
|
|
} |
|
|
return account |
|
|
}) |
|
|
} |
|
|
} catch (error) { |
|
|
console.debug('Failed to load Claude usage data:', error) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const sortAccounts = (field) => { |
|
|
if (field) { |
|
|
if (accountsSortBy.value === field) { |
|
|
accountsSortOrder.value = accountsSortOrder.value === 'asc' ? 'desc' : 'asc' |
|
|
} else { |
|
|
accountsSortBy.value = field |
|
|
accountsSortOrder.value = 'asc' |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const formatNumber = (num) => { |
|
|
if (num === null || num === undefined) return '0' |
|
|
const number = Number(num) |
|
|
if (number >= 1000000) { |
|
|
return (number / 1000000).toFixed(2) |
|
|
} else if (number >= 1000) { |
|
|
return (number / 1000000).toFixed(4) |
|
|
} |
|
|
return (number / 1000000).toFixed(6) |
|
|
} |
|
|
|
|
|
|
|
|
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 |
|
|
} |
|
|
|
|
|
|
|
|
const loadApiKeys = async (forceReload = false) => { |
|
|
if (!forceReload && apiKeysLoaded.value) { |
|
|
return |
|
|
} |
|
|
|
|
|
try { |
|
|
const response = await apiClient.get('/admin/api-keys') |
|
|
if (response.success) { |
|
|
apiKeys.value = response.data || [] |
|
|
apiKeysLoaded.value = true |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const loadAccountGroups = async (forceReload = false) => { |
|
|
if (!forceReload && groupsLoaded.value) { |
|
|
return |
|
|
} |
|
|
|
|
|
try { |
|
|
const response = await apiClient.get('/admin/account-groups') |
|
|
if (response.success) { |
|
|
accountGroups.value = response.data || [] |
|
|
groupsLoaded.value = true |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const clearCache = () => { |
|
|
apiKeysLoaded.value = false |
|
|
groupsLoaded.value = false |
|
|
groupMembersLoaded.value = false |
|
|
accountGroupMap.value.clear() |
|
|
} |
|
|
|
|
|
|
|
|
const filterByPlatform = () => { |
|
|
currentPage.value = 1 |
|
|
loadAccounts() |
|
|
} |
|
|
|
|
|
|
|
|
const filterByGroup = () => { |
|
|
currentPage.value = 1 |
|
|
loadAccounts() |
|
|
} |
|
|
|
|
|
|
|
|
function normalizeProxyData(proxy) { |
|
|
if (!proxy) { |
|
|
return null |
|
|
} |
|
|
|
|
|
let proxyObject = proxy |
|
|
if (typeof proxy === 'string') { |
|
|
try { |
|
|
proxyObject = JSON.parse(proxy) |
|
|
} catch (error) { |
|
|
return null |
|
|
} |
|
|
} |
|
|
|
|
|
if (!proxyObject || typeof proxyObject !== 'object') { |
|
|
return null |
|
|
} |
|
|
|
|
|
const candidate = |
|
|
proxyObject.proxy && typeof proxyObject.proxy === 'object' ? proxyObject.proxy : proxyObject |
|
|
|
|
|
const host = |
|
|
typeof candidate.host === 'string' |
|
|
? candidate.host.trim() |
|
|
: candidate.host !== undefined && candidate.host !== null |
|
|
? String(candidate.host).trim() |
|
|
: '' |
|
|
|
|
|
const port = |
|
|
candidate.port !== undefined && candidate.port !== null ? String(candidate.port).trim() : '' |
|
|
|
|
|
if (!host || !port) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const type = |
|
|
typeof candidate.type === 'string' && candidate.type.trim() ? candidate.type.trim() : 'socks5' |
|
|
|
|
|
const username = |
|
|
typeof candidate.username === 'string' |
|
|
? candidate.username |
|
|
: candidate.username !== undefined && candidate.username !== null |
|
|
? String(candidate.username) |
|
|
: '' |
|
|
|
|
|
const password = |
|
|
typeof candidate.password === 'string' |
|
|
? candidate.password |
|
|
: candidate.password !== undefined && candidate.password !== null |
|
|
? String(candidate.password) |
|
|
: '' |
|
|
|
|
|
return { |
|
|
type, |
|
|
host, |
|
|
port, |
|
|
username, |
|
|
password |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const formatProxyDisplay = (proxy) => { |
|
|
const parsed = normalizeProxyData(proxy) |
|
|
if (!parsed) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const typeShort = parsed.type.toLowerCase() === 'socks5' ? 'S5' : parsed.type.toUpperCase() |
|
|
|
|
|
let host = parsed.host |
|
|
if (host.length > 15) { |
|
|
host = host.substring(0, 12) + '...' |
|
|
} |
|
|
|
|
|
let display = `${typeShort}://${host}:${parsed.port}` |
|
|
|
|
|
if (parsed.username) { |
|
|
display = `${typeShort}://***@${host}:${parsed.port}` |
|
|
} |
|
|
|
|
|
return display |
|
|
} |
|
|
|
|
|
|
|
|
const formatSessionWindow = (windowStart, windowEnd) => { |
|
|
if (!windowStart || !windowEnd) return '--' |
|
|
|
|
|
const start = new Date(windowStart) |
|
|
const end = new Date(windowEnd) |
|
|
|
|
|
const startHour = start.getHours().toString().padStart(2, '0') |
|
|
const startMin = start.getMinutes().toString().padStart(2, '0') |
|
|
const endHour = end.getHours().toString().padStart(2, '0') |
|
|
const endMin = end.getMinutes().toString().padStart(2, '0') |
|
|
|
|
|
return `${startHour}:${startMin} - ${endHour}:${endMin}` |
|
|
} |
|
|
|
|
|
|
|
|
const formatRemainingTime = (minutes) => { |
|
|
if (!minutes || minutes <= 0) return '已结束' |
|
|
|
|
|
const hours = Math.floor(minutes / 60) |
|
|
const mins = minutes % 60 |
|
|
|
|
|
if (hours > 0) { |
|
|
return `${hours}小时${mins}分钟` |
|
|
} |
|
|
return `${mins}分钟` |
|
|
} |
|
|
|
|
|
|
|
|
const formatRateLimitTime = (minutes) => { |
|
|
if (!minutes || minutes <= 0) return '' |
|
|
|
|
|
|
|
|
minutes = Math.floor(minutes) |
|
|
|
|
|
|
|
|
const days = Math.floor(minutes / 1440) |
|
|
const remainingAfterDays = minutes % 1440 |
|
|
const hours = Math.floor(remainingAfterDays / 60) |
|
|
const mins = remainingAfterDays % 60 |
|
|
|
|
|
|
|
|
if (days > 0) { |
|
|
|
|
|
if (hours > 0) { |
|
|
return `${days}天${hours}小时` |
|
|
} |
|
|
return `${days}天` |
|
|
} else if (hours > 0) { |
|
|
|
|
|
if (mins > 0) { |
|
|
return `${hours}小时${mins}分钟` |
|
|
} |
|
|
return `${hours}小时` |
|
|
} else { |
|
|
|
|
|
return `${mins}分钟` |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const openCreateAccountModal = () => { |
|
|
newAccountPlatform.value = null |
|
|
showCreateAccountModal.value = true |
|
|
} |
|
|
|
|
|
|
|
|
const closeCreateAccountModal = () => { |
|
|
showCreateAccountModal.value = false |
|
|
newAccountPlatform.value = null |
|
|
} |
|
|
|
|
|
|
|
|
const editAccount = (account) => { |
|
|
editingAccount.value = account |
|
|
showEditAccountModal.value = true |
|
|
} |
|
|
|
|
|
const getBoundApiKeysForAccount = (account) => { |
|
|
if (!account || !account.id) return [] |
|
|
return apiKeys.value.filter((key) => { |
|
|
const accountId = account.id |
|
|
return ( |
|
|
key.claudeAccountId === accountId || |
|
|
key.claudeConsoleAccountId === accountId || |
|
|
key.geminiAccountId === accountId || |
|
|
key.openaiAccountId === accountId || |
|
|
key.azureOpenaiAccountId === accountId || |
|
|
key.openaiAccountId === `responses:${accountId}` |
|
|
) |
|
|
}) |
|
|
} |
|
|
|
|
|
const resolveAccountDeleteEndpoint = (account) => { |
|
|
switch (account.platform) { |
|
|
case 'claude': |
|
|
return `/admin/claude-accounts/${account.id}` |
|
|
case 'claude-console': |
|
|
return `/admin/claude-console-accounts/${account.id}` |
|
|
case 'bedrock': |
|
|
return `/admin/bedrock-accounts/${account.id}` |
|
|
case 'openai': |
|
|
return `/admin/openai-accounts/${account.id}` |
|
|
case 'azure_openai': |
|
|
return `/admin/azure-openai-accounts/${account.id}` |
|
|
case 'openai-responses': |
|
|
return `/admin/openai-responses-accounts/${account.id}` |
|
|
case 'ccr': |
|
|
return `/admin/ccr-accounts/${account.id}` |
|
|
case 'gemini': |
|
|
return `/admin/gemini-accounts/${account.id}` |
|
|
case 'droid': |
|
|
return `/admin/droid-accounts/${account.id}` |
|
|
default: |
|
|
return null |
|
|
} |
|
|
} |
|
|
|
|
|
const performAccountDeletion = async (account) => { |
|
|
const endpoint = resolveAccountDeleteEndpoint(account) |
|
|
if (!endpoint) { |
|
|
return { success: false, message: '不支持的账户类型' } |
|
|
} |
|
|
|
|
|
try { |
|
|
const data = await apiClient.delete(endpoint) |
|
|
if (data.success) { |
|
|
return { success: true, data } |
|
|
} |
|
|
return { success: false, message: data.message || '删除失败' } |
|
|
} catch (error) { |
|
|
const message = error.response?.data?.message || error.message || '删除失败' |
|
|
return { success: false, message } |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const deleteAccount = async (account) => { |
|
|
const boundKeys = getBoundApiKeysForAccount(account) |
|
|
const boundKeysCount = boundKeys.length |
|
|
|
|
|
let confirmMessage = `确定要删除账户 "${account.name}" 吗?` |
|
|
if (boundKeysCount > 0) { |
|
|
confirmMessage += `\n\n⚠️ 注意:此账号有 ${boundKeysCount} 个 API Key 绑定。` |
|
|
confirmMessage += `\n删除后,这些 API Key 将自动切换为共享池模式。` |
|
|
} |
|
|
confirmMessage += '\n\n此操作不可恢复。' |
|
|
|
|
|
const confirmed = await showConfirm('删除账户', confirmMessage, '删除', '取消') |
|
|
|
|
|
if (!confirmed) return |
|
|
|
|
|
const result = await performAccountDeletion(account) |
|
|
|
|
|
if (result.success) { |
|
|
const data = result.data |
|
|
let toastMessage = '账户已成功删除' |
|
|
if (data?.unboundKeys > 0) { |
|
|
toastMessage += `,${data.unboundKeys} 个 API Key 已切换为共享池模式` |
|
|
} |
|
|
showToast(toastMessage, 'success') |
|
|
|
|
|
selectedAccounts.value = selectedAccounts.value.filter((id) => id !== account.id) |
|
|
updateSelectAllState() |
|
|
|
|
|
groupMembersLoaded.value = false |
|
|
apiKeysLoaded.value = false |
|
|
loadAccounts() |
|
|
loadApiKeys(true) |
|
|
} else { |
|
|
showToast(result.message || '删除失败', 'error') |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const batchDeleteAccounts = async () => { |
|
|
if (selectedAccounts.value.length === 0) { |
|
|
showToast('请先选择要删除的账户', 'warning') |
|
|
return |
|
|
} |
|
|
|
|
|
const accountsMap = new Map(accounts.value.map((item) => [item.id, item])) |
|
|
const targets = selectedAccounts.value |
|
|
.map((id) => accountsMap.get(id)) |
|
|
.filter((account) => !!account) |
|
|
|
|
|
if (targets.length === 0) { |
|
|
showToast('选中的账户已不存在', 'warning') |
|
|
selectedAccounts.value = [] |
|
|
updateSelectAllState() |
|
|
return |
|
|
} |
|
|
|
|
|
let confirmMessage = `确定要删除选中的 ${targets.length} 个账户吗?此操作不可恢复。` |
|
|
const boundInfo = targets |
|
|
.map((account) => ({ account, boundKeys: getBoundApiKeysForAccount(account) })) |
|
|
.filter((item) => item.boundKeys.length > 0) |
|
|
|
|
|
if (boundInfo.length > 0) { |
|
|
confirmMessage += '\n\n⚠️ 以下账户存在绑定的 API Key,将自动解绑:' |
|
|
boundInfo.forEach(({ account, boundKeys }) => { |
|
|
const displayName = account.name || account.email || account.accountName || account.id |
|
|
confirmMessage += `\n- ${displayName}: ${boundKeys.length} 个` |
|
|
}) |
|
|
confirmMessage += '\n删除后,这些 API Key 将切换为共享池模式。' |
|
|
} |
|
|
|
|
|
confirmMessage += '\n\n请再次确认是否继续。' |
|
|
|
|
|
const confirmed = await showConfirm('批量删除账户', confirmMessage, '删除', '取消') |
|
|
if (!confirmed) return |
|
|
|
|
|
let successCount = 0 |
|
|
let failedCount = 0 |
|
|
let totalUnboundKeys = 0 |
|
|
const failedDetails = [] |
|
|
|
|
|
for (const account of targets) { |
|
|
const result = await performAccountDeletion(account) |
|
|
if (result.success) { |
|
|
successCount += 1 |
|
|
totalUnboundKeys += result.data?.unboundKeys || 0 |
|
|
} else { |
|
|
failedCount += 1 |
|
|
failedDetails.push({ |
|
|
name: account.name || account.email || account.accountName || account.id, |
|
|
message: result.message || '删除失败' |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
if (successCount > 0) { |
|
|
let toastMessage = `成功删除 ${successCount} 个账户` |
|
|
if (totalUnboundKeys > 0) { |
|
|
toastMessage += `,${totalUnboundKeys} 个 API Key 已切换为共享池模式` |
|
|
} |
|
|
showToast(toastMessage, failedCount > 0 ? 'warning' : 'success') |
|
|
|
|
|
selectedAccounts.value = [] |
|
|
selectAllChecked.value = false |
|
|
isIndeterminate.value = false |
|
|
|
|
|
groupMembersLoaded.value = false |
|
|
apiKeysLoaded.value = false |
|
|
await loadAccounts(true) |
|
|
} |
|
|
|
|
|
if (failedCount > 0) { |
|
|
const detailMessage = failedDetails.map((item) => `${item.name}: ${item.message}`).join('\n') |
|
|
showToast( |
|
|
`有 ${failedCount} 个账户删除失败:\n${detailMessage}`, |
|
|
successCount > 0 ? 'warning' : 'error' |
|
|
) |
|
|
} |
|
|
|
|
|
updateSelectAllState() |
|
|
} |
|
|
|
|
|
|
|
|
const resetAccountStatus = async (account) => { |
|
|
if (account.isResetting) return |
|
|
|
|
|
let confirmed = false |
|
|
if (window.showConfirm) { |
|
|
confirmed = await window.showConfirm( |
|
|
'重置账户状态', |
|
|
'确定要重置此账户的所有异常状态吗?这将清除限流状态、401错误计数等所有异常标记。', |
|
|
'确定重置', |
|
|
'取消' |
|
|
) |
|
|
} else { |
|
|
confirmed = confirm('确定要重置此账户的所有异常状态吗?') |
|
|
} |
|
|
|
|
|
if (!confirmed) return |
|
|
|
|
|
try { |
|
|
account.isResetting = true |
|
|
|
|
|
|
|
|
let endpoint = '' |
|
|
if (account.platform === 'openai') { |
|
|
endpoint = `/admin/openai-accounts/${account.id}/reset-status` |
|
|
} else if (account.platform === 'openai-responses') { |
|
|
endpoint = `/admin/openai-responses-accounts/${account.id}/reset-status` |
|
|
} else if (account.platform === 'claude') { |
|
|
endpoint = `/admin/claude-accounts/${account.id}/reset-status` |
|
|
} else if (account.platform === 'claude-console') { |
|
|
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status` |
|
|
} else if (account.platform === 'ccr') { |
|
|
endpoint = `/admin/ccr-accounts/${account.id}/reset-status` |
|
|
} else if (account.platform === 'droid') { |
|
|
endpoint = `/admin/droid-accounts/${account.id}/reset-status` |
|
|
} else { |
|
|
showToast('不支持的账户类型', 'error') |
|
|
account.isResetting = false |
|
|
return |
|
|
} |
|
|
|
|
|
const data = await apiClient.post(endpoint) |
|
|
|
|
|
if (data.success) { |
|
|
showToast('账户状态已重置', 'success') |
|
|
|
|
|
loadAccounts(true) |
|
|
} else { |
|
|
showToast(data.message || '状态重置失败', 'error') |
|
|
} |
|
|
} catch (error) { |
|
|
showToast('状态重置失败', 'error') |
|
|
} finally { |
|
|
account.isResetting = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const toggleSchedulable = async (account) => { |
|
|
if (account.isTogglingSchedulable) return |
|
|
|
|
|
try { |
|
|
account.isTogglingSchedulable = true |
|
|
|
|
|
let endpoint |
|
|
if (account.platform === 'claude') { |
|
|
endpoint = `/admin/claude-accounts/${account.id}/toggle-schedulable` |
|
|
} else if (account.platform === 'claude-console') { |
|
|
endpoint = `/admin/claude-console-accounts/${account.id}/toggle-schedulable` |
|
|
} else if (account.platform === 'bedrock') { |
|
|
endpoint = `/admin/bedrock-accounts/${account.id}/toggle-schedulable` |
|
|
} else if (account.platform === 'gemini') { |
|
|
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable` |
|
|
} else if (account.platform === 'openai') { |
|
|
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable` |
|
|
} else if (account.platform === 'azure_openai') { |
|
|
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable` |
|
|
} else if (account.platform === 'openai-responses') { |
|
|
endpoint = `/admin/openai-responses-accounts/${account.id}/toggle-schedulable` |
|
|
} else if (account.platform === 'ccr') { |
|
|
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable` |
|
|
} else if (account.platform === 'droid') { |
|
|
endpoint = `/admin/droid-accounts/${account.id}/toggle-schedulable` |
|
|
} else { |
|
|
showToast('该账户类型暂不支持调度控制', 'warning') |
|
|
return |
|
|
} |
|
|
|
|
|
const data = await apiClient.put(endpoint) |
|
|
|
|
|
if (data.success) { |
|
|
account.schedulable = data.schedulable |
|
|
showToast(data.schedulable ? '已启用调度' : '已禁用调度', 'success') |
|
|
} else { |
|
|
showToast(data.message || '操作失败', 'error') |
|
|
} |
|
|
} catch (error) { |
|
|
showToast('切换调度状态失败', 'error') |
|
|
} finally { |
|
|
account.isTogglingSchedulable = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const handleCreateSuccess = () => { |
|
|
showCreateAccountModal.value = false |
|
|
showToast('账户创建成功', 'success') |
|
|
|
|
|
clearCache() |
|
|
loadAccounts() |
|
|
} |
|
|
|
|
|
|
|
|
const handleEditSuccess = () => { |
|
|
showEditAccountModal.value = false |
|
|
showToast('账户更新成功', 'success') |
|
|
|
|
|
groupMembersLoaded.value = false |
|
|
loadAccounts() |
|
|
} |
|
|
|
|
|
|
|
|
const getClaudeAuthType = (account) => { |
|
|
|
|
|
if (!account.lastRefreshAt || account.lastRefreshAt === '') { |
|
|
return 'Setup' |
|
|
} |
|
|
return 'OAuth' |
|
|
} |
|
|
|
|
|
|
|
|
const getGeminiAuthType = () => { |
|
|
|
|
|
return 'OAuth' |
|
|
} |
|
|
|
|
|
|
|
|
const getOpenAIAuthType = () => { |
|
|
|
|
|
return 'OAuth' |
|
|
} |
|
|
|
|
|
|
|
|
const getDroidAuthType = (account) => { |
|
|
if (!account || typeof account !== 'object') { |
|
|
return 'OAuth' |
|
|
} |
|
|
|
|
|
const apiKeyModeFlag = |
|
|
account.isApiKeyMode ?? account.is_api_key_mode ?? account.apiKeyMode ?? account.api_key_mode |
|
|
|
|
|
if ( |
|
|
apiKeyModeFlag === true || |
|
|
apiKeyModeFlag === 'true' || |
|
|
apiKeyModeFlag === 1 || |
|
|
apiKeyModeFlag === '1' |
|
|
) { |
|
|
return 'API Key' |
|
|
} |
|
|
|
|
|
const methodCandidate = |
|
|
account.authenticationMethod || |
|
|
account.authMethod || |
|
|
account.authentication_mode || |
|
|
account.authenticationMode || |
|
|
account.authentication_method || |
|
|
account.auth_type || |
|
|
account.authType || |
|
|
account.authentication_type || |
|
|
account.authenticationType || |
|
|
account.droidAuthType || |
|
|
account.droidAuthenticationMethod || |
|
|
account.method || |
|
|
account.auth || |
|
|
'' |
|
|
|
|
|
if (typeof methodCandidate === 'string') { |
|
|
const normalized = methodCandidate.trim().toLowerCase() |
|
|
const compacted = normalized.replace(/[\s_-]/g, '') |
|
|
|
|
|
if (compacted === 'apikey') { |
|
|
return 'API Key' |
|
|
} |
|
|
} |
|
|
|
|
|
return 'OAuth' |
|
|
} |
|
|
|
|
|
|
|
|
const isDroidApiKeyMode = (account) => getDroidAuthType(account) === 'API Key' |
|
|
|
|
|
|
|
|
const getDroidApiKeyCount = (account) => { |
|
|
if (!account || typeof account !== 'object') { |
|
|
return 0 |
|
|
} |
|
|
|
|
|
|
|
|
if (Array.isArray(account.apiKeys)) { |
|
|
|
|
|
return account.apiKeys.filter((apiKey) => apiKey.status !== 'error').length |
|
|
} |
|
|
|
|
|
|
|
|
if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) { |
|
|
try { |
|
|
const parsed = JSON.parse(account.apiKeys) |
|
|
if (Array.isArray(parsed)) { |
|
|
|
|
|
return parsed.filter((apiKey) => apiKey.status !== 'error').length |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
const candidates = [ |
|
|
account.apiKeyCount, |
|
|
account.api_key_count, |
|
|
account.apiKeysCount, |
|
|
account.api_keys_count |
|
|
] |
|
|
|
|
|
for (const candidate of candidates) { |
|
|
const value = Number(candidate) |
|
|
if (Number.isFinite(value) && value >= 0) { |
|
|
return value |
|
|
} |
|
|
} |
|
|
|
|
|
return 0 |
|
|
} |
|
|
|
|
|
|
|
|
const getDroidApiKeyBadgeClasses = (account) => { |
|
|
const count = getDroidApiKeyCount(account) |
|
|
const baseClass = |
|
|
'ml-1 inline-flex items-center gap-1 rounded-md border px-1.5 py-[1px] text-[10px] font-medium shadow-sm backdrop-blur-sm' |
|
|
|
|
|
if (count > 0) { |
|
|
return [ |
|
|
baseClass, |
|
|
'border-cyan-200 bg-cyan-50/90 text-cyan-700 dark:border-cyan-500/40 dark:bg-cyan-900/40 dark:text-cyan-200' |
|
|
] |
|
|
} |
|
|
|
|
|
return [ |
|
|
baseClass, |
|
|
'border-rose-200 bg-rose-50/90 text-rose-600 dark:border-rose-500/40 dark:bg-rose-900/40 dark:text-rose-200' |
|
|
] |
|
|
} |
|
|
|
|
|
|
|
|
const getClaudeAccountType = (account) => { |
|
|
|
|
|
if (account.subscriptionInfo) { |
|
|
try { |
|
|
|
|
|
const info = |
|
|
typeof account.subscriptionInfo === 'string' |
|
|
? JSON.parse(account.subscriptionInfo) |
|
|
: account.subscriptionInfo |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (info.hasClaudeMax === true) { |
|
|
return 'Claude Max' |
|
|
} else if (info.hasClaudePro === true) { |
|
|
return 'Claude Pro' |
|
|
} else { |
|
|
return 'Claude Free' |
|
|
} |
|
|
} catch (e) { |
|
|
|
|
|
return 'Claude' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
return 'Claude' |
|
|
} |
|
|
|
|
|
|
|
|
const getSchedulableReason = (account) => { |
|
|
if (account.schedulable !== false) return null |
|
|
|
|
|
|
|
|
if (account.platform === 'claude-console') { |
|
|
if (account.status === 'unauthorized') { |
|
|
return 'API Key无效或已过期(401错误)' |
|
|
} |
|
|
if (account.overloadStatus === 'overloaded') { |
|
|
return '服务过载(529错误)' |
|
|
} |
|
|
if (account.rateLimitStatus === 'limited') { |
|
|
return '触发限流(429错误)' |
|
|
} |
|
|
if (account.status === 'blocked' && account.errorMessage) { |
|
|
return account.errorMessage |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (account.platform === 'claude') { |
|
|
if (account.status === 'unauthorized') { |
|
|
return '认证失败(401错误)' |
|
|
} |
|
|
if (account.status === 'temp_error' && account.errorMessage) { |
|
|
return account.errorMessage |
|
|
} |
|
|
if (account.status === 'error' && account.errorMessage) { |
|
|
return account.errorMessage |
|
|
} |
|
|
if (account.isRateLimited) { |
|
|
return '触发限流(429错误)' |
|
|
} |
|
|
|
|
|
if (account.stoppedReason) { |
|
|
return account.stoppedReason |
|
|
} |
|
|
|
|
|
if (account.fiveHourAutoStopped === 'true' || account.fiveHourAutoStopped === true) { |
|
|
return '5小时使用量接近限制,已自动停止调度' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (account.platform === 'openai') { |
|
|
if (account.status === 'unauthorized') { |
|
|
return '认证失败(401错误)' |
|
|
} |
|
|
|
|
|
if ( |
|
|
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) || |
|
|
account.isRateLimited |
|
|
) { |
|
|
return '触发限流(429错误)' |
|
|
} |
|
|
if (account.status === 'error' && account.errorMessage) { |
|
|
return account.errorMessage |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (account.platform === 'openai-responses') { |
|
|
if (account.status === 'unauthorized') { |
|
|
return '认证失败(401错误)' |
|
|
} |
|
|
|
|
|
if ( |
|
|
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) || |
|
|
account.isRateLimited |
|
|
) { |
|
|
return '触发限流(429错误)' |
|
|
} |
|
|
if (account.status === 'error' && account.errorMessage) { |
|
|
return account.errorMessage |
|
|
} |
|
|
if (account.status === 'rateLimited') { |
|
|
return '触发限流(429错误)' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (account.stoppedReason) { |
|
|
return account.stoppedReason |
|
|
} |
|
|
if (account.errorMessage) { |
|
|
return account.errorMessage |
|
|
} |
|
|
|
|
|
|
|
|
return '手动停止调度' |
|
|
} |
|
|
|
|
|
|
|
|
const getAccountStatusText = (account) => { |
|
|
|
|
|
if (account.status === 'blocked') return '已封锁' |
|
|
|
|
|
if (account.status === 'unauthorized') return '异常' |
|
|
|
|
|
if ( |
|
|
account.isRateLimited || |
|
|
account.status === 'rate_limited' || |
|
|
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) || |
|
|
account.rateLimitStatus === 'limited' |
|
|
) |
|
|
return '限流中' |
|
|
|
|
|
if (account.status === 'temp_error') return '临时异常' |
|
|
|
|
|
if (account.status === 'error' || !account.isActive) return '错误' |
|
|
|
|
|
if (account.schedulable === false) return '已暂停' |
|
|
|
|
|
return '正常' |
|
|
} |
|
|
|
|
|
|
|
|
const getAccountStatusClass = (account) => { |
|
|
if (account.status === 'blocked') { |
|
|
return 'bg-red-100 text-red-800' |
|
|
} |
|
|
if (account.status === 'unauthorized') { |
|
|
return 'bg-red-100 text-red-800' |
|
|
} |
|
|
if ( |
|
|
account.isRateLimited || |
|
|
account.status === 'rate_limited' || |
|
|
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) || |
|
|
account.rateLimitStatus === 'limited' |
|
|
) { |
|
|
return 'bg-orange-100 text-orange-800' |
|
|
} |
|
|
if (account.status === 'temp_error') { |
|
|
return 'bg-orange-100 text-orange-800' |
|
|
} |
|
|
if (account.status === 'error' || !account.isActive) { |
|
|
return 'bg-red-100 text-red-800' |
|
|
} |
|
|
if (account.schedulable === false) { |
|
|
return 'bg-gray-100 text-gray-800' |
|
|
} |
|
|
return 'bg-green-100 text-green-800' |
|
|
} |
|
|
|
|
|
|
|
|
const getAccountStatusDotClass = (account) => { |
|
|
if (account.status === 'blocked') { |
|
|
return 'bg-red-500' |
|
|
} |
|
|
if (account.status === 'unauthorized') { |
|
|
return 'bg-red-500' |
|
|
} |
|
|
if ( |
|
|
account.isRateLimited || |
|
|
account.status === 'rate_limited' || |
|
|
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) || |
|
|
account.rateLimitStatus === 'limited' |
|
|
) { |
|
|
return 'bg-orange-500' |
|
|
} |
|
|
if (account.status === 'temp_error') { |
|
|
return 'bg-orange-500' |
|
|
} |
|
|
if (account.status === 'error' || !account.isActive) { |
|
|
return 'bg-red-500' |
|
|
} |
|
|
if (account.schedulable === false) { |
|
|
return 'bg-gray-500' |
|
|
} |
|
|
return 'bg-green-500' |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const formatRelativeTime = (dateString) => { |
|
|
return formatLastUsed(dateString) |
|
|
} |
|
|
|
|
|
|
|
|
const getSessionProgressBarClass = (status, account = null) => { |
|
|
|
|
|
if (!status) { |
|
|
|
|
|
return 'bg-gradient-to-r from-blue-500 to-indigo-600' |
|
|
} |
|
|
|
|
|
|
|
|
const isRateLimited = |
|
|
account && |
|
|
(account.isRateLimited || |
|
|
account.status === 'rate_limited' || |
|
|
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) || |
|
|
account.rateLimitStatus === 'limited') |
|
|
|
|
|
|
|
|
if (isRateLimited) { |
|
|
return 'bg-gradient-to-r from-red-500 to-red-600' |
|
|
} |
|
|
|
|
|
|
|
|
const normalizedStatus = String(status).toLowerCase() |
|
|
|
|
|
if (normalizedStatus === 'rejected') { |
|
|
|
|
|
return 'bg-gradient-to-r from-red-500 to-red-600' |
|
|
} else if (normalizedStatus === 'allowed_warning') { |
|
|
|
|
|
return 'bg-gradient-to-r from-yellow-500 to-orange-500' |
|
|
} else { |
|
|
|
|
|
return 'bg-gradient-to-r from-blue-500 to-indigo-600' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const isClaudeOAuth = (account) => { |
|
|
return account.authType === 'oauth' |
|
|
} |
|
|
|
|
|
|
|
|
const formatClaudeUsagePercent = (window) => { |
|
|
if (!window || window.utilization === null || window.utilization === undefined) { |
|
|
return '-' |
|
|
} |
|
|
return `${window.utilization}%` |
|
|
} |
|
|
|
|
|
|
|
|
const getClaudeUsageWidth = (window) => { |
|
|
if (!window || window.utilization === null || window.utilization === undefined) { |
|
|
return '0%' |
|
|
} |
|
|
return `${window.utilization}%` |
|
|
} |
|
|
|
|
|
|
|
|
const getClaudeUsageBarClass = (window) => { |
|
|
const util = window?.utilization || 0 |
|
|
if (util < 60) { |
|
|
return 'bg-gradient-to-r from-blue-500 to-indigo-600' |
|
|
} |
|
|
if (util < 90) { |
|
|
return 'bg-gradient-to-r from-yellow-500 to-orange-500' |
|
|
} |
|
|
return 'bg-gradient-to-r from-red-500 to-red-600' |
|
|
} |
|
|
|
|
|
|
|
|
const formatClaudeRemaining = (window) => { |
|
|
if (!window || !window.remainingSeconds) { |
|
|
return '-' |
|
|
} |
|
|
|
|
|
const seconds = window.remainingSeconds |
|
|
const days = Math.floor(seconds / 86400) |
|
|
const hours = Math.floor((seconds % 86400) / 3600) |
|
|
const minutes = Math.floor((seconds % 3600) / 60) |
|
|
|
|
|
if (days > 0) { |
|
|
if (hours > 0) { |
|
|
return `${days}天${hours}小时` |
|
|
} |
|
|
return `${days}天` |
|
|
} |
|
|
if (hours > 0) { |
|
|
if (minutes > 0) { |
|
|
return `${hours}小时${minutes}分钟` |
|
|
} |
|
|
return `${hours}小时` |
|
|
} |
|
|
if (minutes > 0) { |
|
|
return `${minutes}分钟` |
|
|
} |
|
|
return `${Math.floor(seconds % 60)}秒` |
|
|
} |
|
|
|
|
|
|
|
|
const normalizeCodexUsagePercent = (usageItem) => { |
|
|
if (!usageItem) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const basePercent = |
|
|
typeof usageItem.usedPercent === 'number' && !Number.isNaN(usageItem.usedPercent) |
|
|
? usageItem.usedPercent |
|
|
: null |
|
|
|
|
|
const resetAfterSeconds = |
|
|
typeof usageItem.resetAfterSeconds === 'number' && !Number.isNaN(usageItem.resetAfterSeconds) |
|
|
? usageItem.resetAfterSeconds |
|
|
: null |
|
|
|
|
|
const remainingSeconds = |
|
|
typeof usageItem.remainingSeconds === 'number' ? usageItem.remainingSeconds : null |
|
|
|
|
|
const resetAtMs = usageItem.resetAt ? Date.parse(usageItem.resetAt) : null |
|
|
|
|
|
const resetElapsed = |
|
|
resetAfterSeconds !== null && |
|
|
((remainingSeconds !== null && remainingSeconds <= 0) || |
|
|
(resetAtMs !== null && !Number.isNaN(resetAtMs) && Date.now() >= resetAtMs)) |
|
|
|
|
|
if (resetElapsed) { |
|
|
return 0 |
|
|
} |
|
|
|
|
|
if (basePercent === null) { |
|
|
return null |
|
|
} |
|
|
|
|
|
return Math.max(0, Math.min(100, basePercent)) |
|
|
} |
|
|
|
|
|
|
|
|
const getCodexUsageBarClass = (usageItem) => { |
|
|
const percent = normalizeCodexUsagePercent(usageItem) |
|
|
if (percent === null) { |
|
|
return 'bg-gradient-to-r from-gray-300 to-gray-400' |
|
|
} |
|
|
if (percent >= 90) { |
|
|
return 'bg-gradient-to-r from-red-500 to-red-600' |
|
|
} |
|
|
if (percent >= 75) { |
|
|
return 'bg-gradient-to-r from-yellow-500 to-orange-500' |
|
|
} |
|
|
return 'bg-gradient-to-r from-emerald-500 to-teal-500' |
|
|
} |
|
|
|
|
|
|
|
|
const formatCodexUsagePercent = (usageItem) => { |
|
|
const percent = normalizeCodexUsagePercent(usageItem) |
|
|
if (percent === null) { |
|
|
return '--' |
|
|
} |
|
|
return `${percent.toFixed(1)}%` |
|
|
} |
|
|
|
|
|
|
|
|
const getCodexUsageWidth = (usageItem) => { |
|
|
const percent = normalizeCodexUsagePercent(usageItem) |
|
|
if (percent === null) { |
|
|
return '0%' |
|
|
} |
|
|
return `${percent}%` |
|
|
} |
|
|
|
|
|
|
|
|
const getCodexWindowLabel = (type) => { |
|
|
if (type === 'secondary') { |
|
|
return '周限' |
|
|
} |
|
|
return '5h' |
|
|
} |
|
|
|
|
|
|
|
|
const formatCodexRemaining = (usageItem) => { |
|
|
if (!usageItem) { |
|
|
return '--' |
|
|
} |
|
|
|
|
|
let seconds = usageItem.remainingSeconds |
|
|
if (seconds === null || seconds === undefined) { |
|
|
seconds = usageItem.resetAfterSeconds |
|
|
} |
|
|
|
|
|
if (seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) { |
|
|
return '--' |
|
|
} |
|
|
|
|
|
seconds = Math.max(0, Math.floor(Number(seconds))) |
|
|
|
|
|
const days = Math.floor(seconds / 86400) |
|
|
const hours = Math.floor((seconds % 86400) / 3600) |
|
|
const minutes = Math.floor((seconds % 3600) / 60) |
|
|
const secs = seconds % 60 |
|
|
|
|
|
if (days > 0) { |
|
|
if (hours > 0) { |
|
|
return `${days}天${hours}小时` |
|
|
} |
|
|
return `${days}天` |
|
|
} |
|
|
if (hours > 0) { |
|
|
if (minutes > 0) { |
|
|
return `${hours}小时${minutes}分钟` |
|
|
} |
|
|
return `${hours}小时` |
|
|
} |
|
|
if (minutes > 0) { |
|
|
return `${minutes}分钟` |
|
|
} |
|
|
return `${secs}秒` |
|
|
} |
|
|
|
|
|
|
|
|
const formatCost = (cost) => { |
|
|
if (!cost || cost === 0) return '0.0000' |
|
|
if (cost < 0.0001) return cost.toExponential(2) |
|
|
if (cost < 0.01) return cost.toFixed(6) |
|
|
if (cost < 1) return cost.toFixed(4) |
|
|
return cost.toFixed(2) |
|
|
} |
|
|
|
|
|
|
|
|
const getQuotaUsagePercent = (account) => { |
|
|
const used = Number(account?.usage?.daily?.cost || 0) |
|
|
const quota = Number(account?.dailyQuota || 0) |
|
|
if (!quota || quota <= 0) return 0 |
|
|
return (used / quota) * 100 |
|
|
} |
|
|
|
|
|
|
|
|
const getQuotaBarClass = (percent) => { |
|
|
if (percent >= 90) return 'bg-red-500' |
|
|
if (percent >= 70) return 'bg-yellow-500' |
|
|
return 'bg-green-500' |
|
|
} |
|
|
|
|
|
|
|
|
const formatRemainingQuota = (account) => { |
|
|
const used = Number(account?.usage?.daily?.cost || 0) |
|
|
const quota = Number(account?.dailyQuota || 0) |
|
|
if (!quota || quota <= 0) return '0.00' |
|
|
return Math.max(0, quota - used).toFixed(2) |
|
|
} |
|
|
|
|
|
|
|
|
const calculateDailyCost = (account) => { |
|
|
if (!account.usage || !account.usage.daily) return '0.0000' |
|
|
|
|
|
|
|
|
if (account.usage.daily.cost !== undefined) { |
|
|
return formatCost(account.usage.daily.cost) |
|
|
} |
|
|
|
|
|
|
|
|
return '0.0000' |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
watch(searchKeyword, () => { |
|
|
currentPage.value = 1 |
|
|
updateSelectAllState() |
|
|
}) |
|
|
|
|
|
watch(pageSize, (newSize) => { |
|
|
localStorage.setItem(PAGE_SIZE_STORAGE_KEY, newSize.toString()) |
|
|
updateSelectAllState() |
|
|
}) |
|
|
|
|
|
watch( |
|
|
() => sortedAccounts.value.length, |
|
|
() => { |
|
|
if (currentPage.value > totalPages.value) { |
|
|
currentPage.value = totalPages.value || 1 |
|
|
} |
|
|
updateSelectAllState() |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch(accountSortBy, (newVal) => { |
|
|
const fieldMap = { |
|
|
name: 'name', |
|
|
dailyTokens: 'dailyTokens', |
|
|
dailyRequests: 'dailyRequests', |
|
|
totalTokens: 'totalTokens', |
|
|
lastUsed: 'lastUsed' |
|
|
} |
|
|
|
|
|
if (fieldMap[newVal]) { |
|
|
sortAccounts(fieldMap[newVal]) |
|
|
} |
|
|
}) |
|
|
|
|
|
watch(currentPage, () => { |
|
|
updateSelectAllState() |
|
|
}) |
|
|
|
|
|
watch(paginatedAccounts, () => { |
|
|
updateSelectAllState() |
|
|
}) |
|
|
|
|
|
watch(accounts, () => { |
|
|
cleanupSelectedAccounts() |
|
|
}) |
|
|
|
|
|
const formatExpireDate = (dateString) => { |
|
|
if (!dateString) return '' |
|
|
const date = new Date(dateString) |
|
|
return date.toLocaleDateString('zh-CN', { |
|
|
year: 'numeric', |
|
|
month: '2-digit', |
|
|
day: '2-digit' |
|
|
}) |
|
|
} |
|
|
|
|
|
const isExpired = (expiresAt) => { |
|
|
if (!expiresAt) return false |
|
|
return new Date(expiresAt) < new Date() |
|
|
} |
|
|
|
|
|
const isExpiringSoon = (expiresAt) => { |
|
|
if (!expiresAt) return false |
|
|
const now = new Date() |
|
|
const expireDate = new Date(expiresAt) |
|
|
const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24) |
|
|
return daysUntilExpire > 0 && daysUntilExpire <= 7 |
|
|
} |
|
|
|
|
|
|
|
|
const startEditAccountExpiry = (account) => { |
|
|
editingExpiryAccount.value = account |
|
|
} |
|
|
|
|
|
|
|
|
const closeAccountExpiryEdit = () => { |
|
|
editingExpiryAccount.value = null |
|
|
} |
|
|
|
|
|
|
|
|
const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => { |
|
|
try { |
|
|
|
|
|
const account = accounts.value.find((acc) => acc.id === accountId) |
|
|
|
|
|
if (!account) { |
|
|
showToast('未找到账户', 'error') |
|
|
return |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let endpoint = '' |
|
|
switch (account.platform) { |
|
|
case 'claude': |
|
|
case 'claude-oauth': |
|
|
endpoint = `/admin/claude-accounts/${accountId}` |
|
|
break |
|
|
case 'gemini': |
|
|
endpoint = `/admin/gemini-accounts/${accountId}` |
|
|
break |
|
|
case 'claude-console': |
|
|
endpoint = `/admin/claude-console-accounts/${accountId}` |
|
|
break |
|
|
case 'bedrock': |
|
|
endpoint = `/admin/bedrock-accounts/${accountId}` |
|
|
break |
|
|
case 'ccr': |
|
|
endpoint = `/admin/ccr-accounts/${accountId}` |
|
|
break |
|
|
case 'openai': |
|
|
endpoint = `/admin/openai-accounts/${accountId}` |
|
|
break |
|
|
case 'droid': |
|
|
endpoint = `/admin/droid-accounts/${accountId}` |
|
|
break |
|
|
case 'azure_openai': |
|
|
endpoint = `/admin/azure-openai-accounts/${accountId}` |
|
|
break |
|
|
case 'openai-responses': |
|
|
endpoint = `/admin/openai-responses-accounts/${accountId}` |
|
|
break |
|
|
default: |
|
|
showToast(`不支持的平台类型: ${account.platform}`, 'error') |
|
|
return |
|
|
} |
|
|
|
|
|
const data = await apiClient.put(endpoint, { |
|
|
expiresAt: expiresAt || null |
|
|
}) |
|
|
|
|
|
if (data.success) { |
|
|
showToast('账户到期时间已更新', 'success') |
|
|
|
|
|
account.expiresAt = expiresAt || null |
|
|
closeAccountExpiryEdit() |
|
|
} else { |
|
|
showToast(data.message || '更新失败', 'error') |
|
|
|
|
|
if (expiryEditModalRef.value) { |
|
|
expiryEditModalRef.value.resetSaving() |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('更新账户过期时间失败:', error) |
|
|
showToast('更新失败', 'error') |
|
|
|
|
|
if (expiryEditModalRef.value) { |
|
|
expiryEditModalRef.value.resetSaving() |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
onMounted(() => { |
|
|
|
|
|
loadAccounts(true) |
|
|
}) |
|
|
</script> |
|
|
|
|
|
<style scoped> |
|
|
.table-container { |
|
|
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); |
|
|
} |
|
|
} |
|
|
.accounts-container { |
|
|
min-height: calc(100vh - 300px); |
|
|
} |
|
|
|
|
|
.table-row { |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.table-row:hover { |
|
|
background-color: rgba(0, 0, 0, 0.02); |
|
|
} |
|
|
</style> |
|
|
|