Files
claude-relay-service/web/admin-spa/src/views/AccountsView.vue
2025-10-09 23:05:09 +08:00

3481 lines
137 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

<template>
<div class="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">
管理 ClaudeGeminiOpenAI 等账户与代理配置
</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-[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">OAuth</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
: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">
<!-- OAuth 账户:显示三窗口 OAuth usage -->
<div v-if="isClaudeOAuth(account) && account.claudeUsage" class="space-y-2">
<!-- 5小时窗口 -->
<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>
<!-- 7天窗口 -->
<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>
<!-- 7天Opus窗口 -->
<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>
<!-- Setup Token 账户:显示原有的会话窗口时间进度 -->
<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>
<!-- Claude Console: 显示每日额度使用进度 -->
<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">
<!-- OAuth 账户:显示三窗口 OAuth usage -->
<div v-if="isClaudeOAuth(account) && account.claudeUsage" class="space-y-2">
<!-- 5小时窗口 -->
<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>
<!-- 7天窗口 -->
<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>
<!-- 7天Opus窗口 -->
<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>
<!-- Setup Token 账户:显示原有的会话窗口时间进度 -->
<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"
/>
</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 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']
// 缓存状态标志
const apiKeysLoaded = ref(false)
const groupsLoaded = ref(false)
const groupMembersLoaded = ref(false)
const accountGroupMap = ref(new Map()) // Map<accountId, Array<groupInfo>>
// 下拉选项数据
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' : 'OpenAI'})`,
icon:
group.platform === 'claude'
? 'fa-brain'
: group.platform === 'gemini'
? 'fa-robot'
: 'fa-openai'
})
})
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 {
// 只请求指定平台其他平台设为null占位
switch (platformFilter.value) {
case 'claude':
requests.push(
apiClient.get('/admin/claude-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
Promise.resolve({ success: true, data: [] }), // ccr 占位
Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'claude-console':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
apiClient.get('/admin/claude-console-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
Promise.resolve({ success: true, data: [] }), // ccr 占位
Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'bedrock':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
apiClient.get('/admin/bedrock-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
Promise.resolve({ success: true, data: [] }), // ccr 占位
Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'gemini':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
apiClient.get('/admin/gemini-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
Promise.resolve({ success: true, data: [] }), // ccr 占位
Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'openai':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
apiClient.get('/admin/openai-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
Promise.resolve({ success: true, data: [] }), // ccr 占位
Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'azure_openai':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
apiClient.get('/admin/azure-openai-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
Promise.resolve({ success: true, data: [] }), // ccr 占位
Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'openai-responses':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
apiClient.get('/admin/openai-responses-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // ccr 占位
Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'ccr':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure 占位
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
apiClient.get('/admin/ccr-accounts', { params }),
Promise.resolve({ success: true, data: [] }) // droid 占位
)
break
case 'droid':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }), // azure 占位
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
Promise.resolve({ success: true, data: [] }), // ccr 占位
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
}
}
// 使用缓存机制加载 API Keys 和分组数据
await Promise.all([loadApiKeys(forceReload), loadAccountGroups(forceReload)])
// 后端账户API已经包含分组信息不需要单独加载分组成员关系
// await loadGroupMembers(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) => {
// 计算每个Claude账户绑定的API Key数量
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.claudeAccountId === acc.id
).length
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'claude', boundApiKeysCount }
})
allAccounts.push(...claudeAccounts)
}
if (claudeConsoleData.success) {
const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => {
// 计算每个Claude Console账户绑定的API Key数量
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.claudeConsoleAccountId === acc.id
).length
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'claude-console', boundApiKeysCount }
})
allAccounts.push(...claudeConsoleAccounts)
}
if (bedrockData.success) {
const bedrockAccounts = (bedrockData.data || []).map((acc) => {
// Bedrock账户暂时不支持直接绑定
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 }
})
allAccounts.push(...bedrockAccounts)
}
if (geminiData.success) {
const geminiAccounts = (geminiData.data || []).map((acc) => {
// 计算每个Gemini账户绑定的API Key数量
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.geminiAccountId === acc.id
).length
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'gemini', boundApiKeysCount }
})
allAccounts.push(...geminiAccounts)
}
if (openaiData.success) {
const openaiAccounts = (openaiData.data || []).map((acc) => {
// 计算每个OpenAI账户绑定的API Key数量
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.openaiAccountId === acc.id
).length
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'openai', boundApiKeysCount }
})
allAccounts.push(...openaiAccounts)
}
if (azureOpenaiData && azureOpenaiData.success) {
const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => {
// 计算每个Azure OpenAI账户绑定的API Key数量
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.azureOpenaiAccountId === acc.id
).length
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'azure_openai', boundApiKeysCount }
})
allAccounts.push(...azureOpenaiAccounts)
}
if (openaiResponsesData && openaiResponsesData.success) {
const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => {
// 计算每个OpenAI-Responses账户绑定的API Key数量
// OpenAI-Responses账户使用 responses: 前缀
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.openaiAccountId === `responses:${acc.id}`
).length
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'openai-responses', boundApiKeysCount }
})
allAccounts.push(...openaiResponsesAccounts)
}
// CCR 账户
if (ccrData && ccrData.success) {
const ccrAccounts = (ccrData.data || []).map((acc) => {
// CCR 不支持 API Key 绑定,固定为 0
return { ...acc, platform: 'ccr', boundApiKeysCount: 0 }
})
allAccounts.push(...ccrAccounts)
}
// Droid 账户
if (droidData && droidData.success) {
const droidAccounts = (droidData.data || []).map((acc) => {
// Droid 不支持 API Key 绑定,固定为 0
return { ...acc, platform: 'droid', boundApiKeysCount: 0 }
})
allAccounts.push(...droidAccounts)
}
// 根据分组筛选器过滤账户
let filteredAccounts = allAccounts
if (groupFilter.value !== 'all') {
if (groupFilter.value === 'ungrouped') {
// 筛选未分组的账户(没有 groupInfos 或 groupInfos 为空数组)
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)
})
}
}
accounts.value = filteredAccounts
cleanupSelectedAccounts()
// 异步加载 Claude OAuth 账户的 usage 数据
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
}
}
// 异步加载 Claude 账户的 Usage 数据
const loadClaudeUsage = async () => {
try {
const response = await apiClient.get('/admin/claude-accounts/usage')
if (response.success && response.data) {
const usageMap = response.data
// 更新账户列表中的 claudeUsage 数据
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
}
// 加载API Keys列表缓存版本
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()
}
// 格式化代理信息显示
const formatProxyDisplay = (proxy) => {
if (!proxy || !proxy.host || !proxy.port) return null
// 缩短类型名称
const typeShort = proxy.type === 'socks5' ? 'S5' : proxy.type.toUpperCase()
// 缩短主机名(如果太长)
let host = proxy.host
if (host.length > 15) {
host = host.substring(0, 12) + '...'
}
let display = `${typeShort}://${host}:${proxy.port}`
// 如果有用户名密码,添加认证信息(部分隐藏)
if (proxy.username) {
display = `${typeShort}://***@${host}:${proxy.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) // 1天 = 1440分钟
const remainingAfterDays = minutes % 1440
const hours = Math.floor(remainingAfterDays / 60)
const mins = remainingAfterDays % 60
// 根据时间长度返回不同格式
if (days > 0) {
// 超过1天显示天数和小时
if (hours > 0) {
return `${days}天${hours}小时`
}
return `${days}天`
} else if (hours > 0) {
// 超过1小时但不到1天显示小时和分钟
if (mins > 0) {
return `${hours}小时${mins}分钟`
}
return `${hours}小时`
} else {
// 不到1小时只显示分钟
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
// 根据账户平台选择不同的 API 端点
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()
}
// 获取 Claude 账号的添加方式
const getClaudeAuthType = (account) => {
// 基于 lastRefreshAt 判断:如果为空说明是 Setup Token不能刷新否则是 OAuth
if (!account.lastRefreshAt || account.lastRefreshAt === '') {
return 'Setup' // 缩短显示文本
}
return 'OAuth'
}
// 获取 Gemini 账号的添加方式
const getGeminiAuthType = () => {
// Gemini 统一显示 OAuth
return 'OAuth'
}
// 获取 OpenAI 账号的添加方式
const getOpenAIAuthType = () => {
// OpenAI 统一显示 OAuth
return 'OAuth'
}
// 获取 Claude 账号类型显示
const getClaudeAccountType = (account) => {
// 如果有订阅信息
if (account.subscriptionInfo) {
try {
// 如果 subscriptionInfo 是字符串,尝试解析
const info =
typeof account.subscriptionInfo === 'string'
? JSON.parse(account.subscriptionInfo)
: account.subscriptionInfo
// 订阅信息已解析
// 根据 has_claude_max 和 has_claude_pro 判断
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
// Claude Console 账户的错误状态
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
}
}
// Claude 官方账户的错误状态
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
}
// 检查5小时限制自动停止标志备用方案
if (account.fiveHourAutoStopped === 'true' || account.fiveHourAutoStopped === true) {
return '5小时使用量接近限制已自动停止调度'
}
}
// OpenAI 账户的错误状态
if (account.platform === 'openai') {
if (account.status === 'unauthorized') {
return '认证失败401错误'
}
// 检查限流状态 - 兼容嵌套的 rateLimitStatus 对象
if (
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
account.isRateLimited
) {
return '触发限流429错误'
}
if (account.status === 'error' && account.errorMessage) {
return account.errorMessage
}
}
// OpenAI-Responses 账户的错误状态
if (account.platform === 'openai-responses') {
if (account.status === 'unauthorized') {
return '认证失败401错误'
}
// 检查限流状态 - 兼容嵌套的 rateLimitStatus 对象
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 '已封锁'
// 检查是否未授权401错误
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 getSessionWindowPercentage = (account) => {
// if (!account.sessionWindow) return 100
// const { remaining, total } = account.sessionWindow
// if (!total || total === 0) return 100
// return Math.round((remaining / total) * 100)
// }
// 格式化相对时间
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 {
// 正常状态allowed 或其他) - 蓝色
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
}
}
// ====== Claude OAuth Usage 相关函数 ======
// 判断 Claude 账户是否为 OAuth 授权
const isClaudeOAuth = (account) => {
return account.authType === 'oauth'
}
// 格式化 Claude 使用率百分比
const formatClaudeUsagePercent = (window) => {
if (!window || window.utilization === null || window.utilization === undefined) {
return '-'
}
return `${window.utilization}%`
}
// 获取 Claude 使用率宽度
const getClaudeUsageWidth = (window) => {
if (!window || window.utilization === null || window.utilization === undefined) {
return '0%'
}
return `${window.utilization}%`
}
// 获取 Claude 使用率进度条颜色
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'
}
// 格式化 Claude 剩余时间
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)}`
}
// 归一化 OpenAI 会话窗口使用率
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))
}
// OpenAI 限额进度条颜色
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)
}
// 额度使用百分比Claude Console
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
}
// 额度进度条颜色Claude Console
const getQuotaBarClass = (percent) => {
if (percent >= 90) return 'bg-red-500'
if (percent >= 70) return 'bg-yellow-500'
return 'bg-green-500'
}
// 剩余额度Claude Console
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)
}
// 如果后端没有返回费用旧版本返回0
return '0.0000'
}
// 切换调度状态
// const toggleDispatch = async (account) => {
// await toggleSchedulable(account)
// }
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()
})
onMounted(() => {
// 首次加载时强制刷新所有数据
loadAccounts(true)
})
</script>
<style scoped>
.table-container {
overflow-x: auto;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e5e7eb;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.accounts-container {
min-height: calc(100vh - 300px);
}
.table-container {
overflow-x: auto;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
</style>