cc / web /admin-spa /src /views /AccountsView.vue
hequ's picture
Upload 224 files
75031b4 verified
<template>
<div class="accounts-container">
<div class="card p-4 sm:p-6">
<div class="mb-4 flex flex-col gap-4 sm:mb-6">
<div>
<h3 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100 sm:mb-2 sm:text-xl">
账户管理
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
管理 Claude、Gemini、OpenAI 等账户与代理配置
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<!-- 筛选器组 -->
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<!-- 排序选择器 -->
<div class="group relative min-w-[160px]">
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-indigo-500 to-blue-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<CustomDropdown
v-model="accountSortBy"
icon="fa-sort-amount-down"
icon-color="text-indigo-500"
:options="sortOptions"
placeholder="选择排序"
@change="sortAccounts()"
/>
</div>
<!-- 平台筛选器 -->
<div class="group relative min-w-[140px]">
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<CustomDropdown
v-model="platformFilter"
icon="fa-server"
icon-color="text-blue-500"
:options="platformOptions"
placeholder="选择平台"
@change="filterByPlatform"
/>
</div>
<!-- 分组筛选器 -->
<div class="group relative min-w-[160px]">
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<CustomDropdown
v-model="groupFilter"
icon="fa-layer-group"
icon-color="text-purple-500"
:options="groupOptions"
placeholder="选择分组"
@change="filterByGroup"
/>
</div>
<!-- 搜索框 -->
<div class="group relative min-w-[200px]">
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-cyan-500 to-teal-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<div class="relative flex items-center">
<input
v-model="searchKeyword"
class="h-10 w-full rounded-lg border border-gray-200 bg-white px-3 pl-9 text-sm text-gray-700 placeholder-gray-400 shadow-sm transition-all duration-200 hover:border-gray-300 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500 dark:hover:border-gray-500"
placeholder="搜索账户名称..."
type="text"
/>
<i class="fas fa-search absolute left-3 text-sm text-cyan-500" />
<button
v-if="searchKeyword"
class="absolute right-2 flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
@click="clearSearch"
>
<i class="fas fa-times text-xs" />
</button>
</div>
</div>
</div>
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center sm:gap-3">
<!-- 刷新按钮 -->
<div class="relative">
<el-tooltip
content="刷新数据 (Ctrl/⌘+点击强制刷新所有缓存)"
effect="dark"
placement="bottom"
>
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
:disabled="accountsLoading"
@click.ctrl.exact="loadAccounts(true)"
@click.exact="loadAccounts(false)"
@click.meta.exact="loadAccounts(true)"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-green-500 to-teal-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i
:class="[
'fas relative text-green-500',
accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt'
]"
/>
<span class="relative">刷新</span>
</button>
</el-tooltip>
</div>
<!-- 选择/取消选择按钮 -->
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@click="toggleSelectionMode"
>
<i :class="showCheckboxes ? 'fas fa-times' : 'fas fa-check-square'"></i>
<span>{{ showCheckboxes ? '取消选择' : '选择' }}</span>
</button>
<!-- 批量删除按钮 -->
<button
v-if="selectedAccounts.length > 0"
class="group relative flex items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition-all duration-200 hover:border-red-300 hover:bg-red-100 hover:shadow-md dark:border-red-700 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 sm:w-auto"
@click="batchDeleteAccounts"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-red-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-trash relative text-red-600 dark:text-red-400" />
<span class="relative">删除选中 ({{ selectedAccounts.length }})</span>
</button>
<!-- 添加账户按钮 -->
<button
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-green-500 to-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all duration-200 hover:from-green-600 hover:to-green-700 hover:shadow-lg sm:w-auto"
@click.stop="openCreateAccountModal"
>
<i class="fas fa-plus"></i>
<span>添加账户</span>
</button>
</div>
</div>
</div>
<div v-if="accountsLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">正在加载账户...</p>
</div>
<div v-else-if="sortedAccounts.length === 0" class="py-12 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700"
>
<i class="fas fa-user-circle text-xl text-gray-400" />
</div>
<p class="text-lg text-gray-500 dark:text-gray-400">暂无账户</p>
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500">点击上方按钮添加您的第一个账户</p>
</div>
<!-- 桌面端表格视图 -->
<div v-else class="table-container hidden md:block">
<table class="w-full table-fixed">
<thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
<tr>
<th v-if="shouldShowCheckboxes" class="w-[50px] px-3 py-4 text-left">
<div class="flex items-center">
<input
v-model="selectAllChecked"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
:indeterminate="isIndeterminate"
type="checkbox"
@change="handleSelectAll"
/>
</div>
</th>
<th
class="w-[22%] min-w-[180px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('name')"
>
名称
<i
v-if="accountsSortBy === 'name'"
:class="[
'fas',
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="w-[15%] min-w-[120px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('platform')"
>
平台/类型
<i
v-if="accountsSortBy === 'platform'"
:class="[
'fas',
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="w-[12%] min-w-[110px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('expiresAt')"
>
到期时间
<i
v-if="accountsSortBy === 'expiresAt'"
:class="[
'fas',
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="w-[12%] min-w-[100px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('status')"
>
状态
<i
v-if="accountsSortBy === 'status'"
:class="[
'fas',
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="w-[8%] min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('priority')"
>
优先级
<i
v-if="accountsSortBy === 'priority'"
:class="[
'fas',
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
代理
</th>
<th
class="w-[10%] min-w-[90px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
今日使用
</th>
<th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
<div class="flex items-center gap-2">
<span>会话窗口</span>
<el-tooltip placement="top">
<template #content>
<div
class="w-[260px] space-y-3 text-xs leading-relaxed text-white dark:text-gray-800"
>
<div class="space-y-2">
<div class="text-sm font-semibold text-white dark:text-gray-900">
Claude 系列
</div>
<div class="text-gray-200 dark:text-gray-600">
会话窗口进度表示 5 小时窗口的时间推移,颜色提示当前调度状态。
</div>
<div class="space-y-1 pt-1 text-gray-200 dark:text-gray-600">
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600"
></div>
<span class="font-medium text-white dark:text-gray-900"
>正常:请求正常处理</span
>
</div>
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500"
></div>
<span class="font-medium text-white dark:text-gray-900"
>警告:接近限制</span
>
</div>
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600"
></div>
<span class="font-medium text-white dark:text-gray-900"
>拒绝:达到速率限制</span
>
</div>
</div>
</div>
<div class="h-px bg-gray-200 dark:bg-gray-600/50"></div>
<div class="space-y-2">
<div class="text-sm font-semibold text-white dark:text-gray-900">
OpenAI
</div>
<div class="text-gray-200 dark:text-gray-600">
进度条分别展示 5h 与周限窗口的额度使用比例,颜色含义与上方保持一致。
</div>
<div class="space-y-1 text-gray-200 dark:text-gray-600">
<div class="flex items-start gap-2">
<i class="fas fa-clock mt-[2px] text-[10px] text-blue-500"></i>
<span class="font-medium text-white dark:text-gray-900"
>5h 窗口:5小时使用量进度,到达重置时间后会自动归零。</span
>
</div>
<div class="flex items-start gap-2">
<i class="fas fa-history mt-[2px] text-[10px] text-emerald-500"></i>
<span class="font-medium text-white dark:text-gray-900"
>周限窗口:7天使用量进度,重置时同样回到 0%。</span
>
</div>
<div class="flex items-start gap-2">
<i
class="fas fa-info-circle mt-[2px] text-[10px] text-indigo-500"
></i>
<span class="font-medium text-white dark:text-gray-900"
>当“重置剩余”为 0 时,进度条与百分比会同步清零。</span
>
</div>
</div>
</div>
<div class="h-px bg-gray-200 dark:bg-gray-600/50"></div>
<div class="space-y-2">
<div class="text-sm font-semibold text-white dark:text-gray-900">
Claude OAuth 账户
</div>
<div class="text-gray-200 dark:text-gray-600">
展示三个窗口的使用率(utilization百分比),颜色含义同上。
</div>
<div class="space-y-1 text-gray-200 dark:text-gray-600">
<div class="flex items-start gap-2">
<i class="fas fa-clock mt-[2px] text-[10px] text-indigo-500"></i>
<span class="font-medium text-white dark:text-gray-900"
>5h 窗口:5小时滑动窗口的使用率。</span
>
</div>
<div class="flex items-start gap-2">
<i
class="fas fa-calendar-alt mt-[2px] text-[10px] text-emerald-500"
></i>
<span class="font-medium text-white dark:text-gray-900"
>7d 窗口:7天总限额的使用率。</span
>
</div>
<div class="flex items-start gap-2">
<i class="fas fa-gem mt-[2px] text-[10px] text-purple-500"></i>
<span class="font-medium text-white dark:text-gray-900"
>Opus 窗口:7天Opus模型专用限额。</span
>
</div>
<div class="flex items-start gap-2">
<i class="fas fa-sync-alt mt-[2px] text-[10px] text-blue-500"></i>
<span class="font-medium text-white dark:text-gray-900"
>到达重置时间后自动归零。</span
>
</div>
</div>
</div>
</div>
</template>
<i
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
/>
</el-tooltip>
</div>
</th>
<th
class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
最后使用
</th>
<th
class="w-[15%] min-w-[180px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
操作
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
<tr v-for="account in paginatedAccounts" :key="account.id" class="table-row">
<td v-if="shouldShowCheckboxes" class="px-3 py-3">
<div class="flex items-center">
<input
v-model="selectedAccounts"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
type="checkbox"
:value="account.id"
@change="updateSelectAllState"
/>
</div>
</td>
<td class="px-3 py-4">
<div class="flex items-center">
<div
class="mr-2 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-green-500 to-green-600"
>
<i class="fas fa-user-circle text-xs text-white" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
:title="account.name"
>
{{ account.name }}
</div>
<span
v-if="account.accountType === 'dedicated'"
class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800"
>
<i class="fas fa-lock mr-1" />专属
</span>
<span
v-else-if="account.accountType === 'group'"
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
>
<i class="fas fa-layer-group mr-1" />分组调度
</span>
<span
v-else
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
>
<i class="fas fa-share-alt mr-1" />共享
</span>
</div>
<!-- 显示所有分组 - 换行显示 -->
<div
v-if="account.groupInfos && account.groupInfos.length > 0"
class="my-2 flex flex-wrap items-center gap-2"
>
<span
v-for="group in account.groupInfos"
:key="group.id"
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"
:title="`所属分组: ${group.name}`"
>
<i class="fas fa-folder mr-1" />{{ group.name }}
</span>
</div>
<div
class="truncate text-xs text-gray-500 dark:text-gray-400"
:title="account.id"
>
{{ account.id }}
</div>
</div>
</div>
</td>
<td class="px-3 py-4">
<div class="flex items-center gap-1">
<!-- 平台图标和名称 -->
<div
v-if="account.platform === 'gemini'"
class="flex items-center gap-1.5 rounded-lg border border-yellow-200 bg-gradient-to-r from-yellow-100 to-amber-100 px-2.5 py-1"
>
<i class="fas fa-robot text-xs text-yellow-700" />
<span class="text-xs font-semibold text-yellow-800">Gemini</span>
<span class="mx-1 h-4 w-px bg-yellow-300" />
<span class="text-xs font-medium text-yellow-700">
{{ getGeminiAuthType() }}
</span>
</div>
<div
v-else-if="account.platform === 'claude-console'"
class="flex items-center gap-1.5 rounded-lg border border-purple-200 bg-gradient-to-r from-purple-100 to-pink-100 px-2.5 py-1"
>
<i class="fas fa-terminal text-xs text-purple-700" />
<span class="text-xs font-semibold text-purple-800">Console</span>
<span class="mx-1 h-4 w-px bg-purple-300" />
<span class="text-xs font-medium text-purple-700">API Key</span>
</div>
<div
v-else-if="account.platform === 'bedrock'"
class="flex items-center gap-1.5 rounded-lg border border-orange-200 bg-gradient-to-r from-orange-100 to-red-100 px-2.5 py-1"
>
<i class="fab fa-aws text-xs text-orange-700" />
<span class="text-xs font-semibold text-orange-800">Bedrock</span>
<span class="mx-1 h-4 w-px bg-orange-300" />
<span class="text-xs font-medium text-orange-700">AWS</span>
</div>
<div
v-else-if="account.platform === 'openai'"
class="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-100 bg-gradient-to-r from-gray-100 to-gray-100 px-2.5 py-1"
>
<div class="fa-openai" />
<span class="text-xs font-semibold text-gray-950">OpenAi</span>
<span class="mx-1 h-4 w-px bg-gray-400" />
<span class="text-xs font-medium text-gray-950">{{ getOpenAIAuthType() }}</span>
</div>
<div
v-else-if="account.platform === 'azure_openai'"
class="flex items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-100 to-cyan-100 px-2.5 py-1 dark:border-blue-700 dark:from-blue-900/20 dark:to-cyan-900/20"
>
<i class="fab fa-microsoft text-xs text-blue-700 dark:text-blue-400" />
<span class="text-xs font-semibold text-blue-800 dark:text-blue-300"
>Azure OpenAI</span
>
<span class="mx-1 h-4 w-px bg-blue-300 dark:bg-blue-600" />
<span class="text-xs font-medium text-blue-700 dark:text-blue-400"
>API Key</span
>
</div>
<div
v-else-if="account.platform === 'openai-responses'"
class="flex items-center gap-1.5 rounded-lg border border-teal-200 bg-gradient-to-r from-teal-100 to-green-100 px-2.5 py-1 dark:border-teal-700 dark:from-teal-900/20 dark:to-green-900/20"
>
<i class="fas fa-server text-xs text-teal-700 dark:text-teal-400" />
<span class="text-xs font-semibold text-teal-800 dark:text-teal-300"
>OpenAI-Responses</span
>
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" />
<span class="text-xs font-medium text-teal-700 dark:text-teal-400"
>API Key</span
>
</div>
<div
v-else-if="account.platform === 'claude' || account.platform === 'claude-oauth'"
class="flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100 px-2.5 py-1"
>
<i class="fas fa-brain text-xs text-indigo-700" />
<span class="text-xs font-semibold text-indigo-800">{{
getClaudeAccountType(account)
}}</span>
<span class="mx-1 h-4 w-px bg-indigo-300" />
<span class="text-xs font-medium text-indigo-700">
{{ getClaudeAuthType(account) }}
</span>
</div>
<div
v-else-if="account.platform === 'ccr'"
class="flex items-center gap-1.5 rounded-lg border border-teal-200 bg-gradient-to-r from-teal-100 to-emerald-100 px-2.5 py-1 dark:border-teal-700 dark:from-teal-900/20 dark:to-emerald-900/20"
>
<i class="fas fa-code-branch text-xs text-teal-700 dark:text-teal-400" />
<span class="text-xs font-semibold text-teal-800 dark:text-teal-300">CCR</span>
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" />
<span class="text-xs font-medium text-teal-700 dark:text-teal-300">Relay</span>
</div>
<div
v-else-if="account.platform === 'droid'"
class="flex items-center gap-1.5 rounded-lg border border-cyan-200 bg-gradient-to-r from-cyan-100 to-sky-100 px-2.5 py-1 dark:border-cyan-700 dark:from-cyan-900/20 dark:to-sky-900/20"
>
<i class="fas fa-robot text-xs text-cyan-700 dark:text-cyan-400" />
<span class="text-xs font-semibold text-cyan-800 dark:text-cyan-300"
>Droid</span
>
<span class="mx-1 h-4 w-px bg-cyan-300 dark:bg-cyan-600" />
<span class="text-xs font-medium text-cyan-700 dark:text-cyan-300">
{{ getDroidAuthType(account) }}
</span>
<span
v-if="isDroidApiKeyMode(account)"
:class="getDroidApiKeyBadgeClasses(account)"
>
<i class="fas fa-key text-[9px]" />
<span>x{{ getDroidApiKeyCount(account) }}</span>
</span>
</div>
<div
v-else
class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1"
>
<i class="fas fa-question text-xs text-gray-700" />
<span class="text-xs font-semibold text-gray-800">未知</span>
</div>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div class="flex flex-col gap-1">
<!-- 已设置过期时间 -->
<span v-if="account.expiresAt">
<span
v-if="isExpired(account.expiresAt)"
class="inline-flex cursor-pointer items-center text-red-600 hover:underline"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
<i class="fas fa-exclamation-circle mr-1 text-xs" />
已过期
</span>
<span
v-else-if="isExpiringSoon(account.expiresAt)"
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
<i class="fas fa-clock mr-1 text-xs" />
{{ formatExpireDate(account.expiresAt) }}
</span>
<span
v-else
class="cursor-pointer text-gray-600 hover:underline dark:text-gray-400"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
{{ formatExpireDate(account.expiresAt) }}
</span>
</span>
<!-- 永不过期 -->
<span
v-else
class="inline-flex cursor-pointer items-center text-gray-400 hover:underline dark:text-gray-500"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
<i class="fas fa-infinity mr-1 text-xs" />
永不过期
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div class="flex flex-col gap-1">
<span
:class="[
'inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold',
account.status === 'blocked'
? 'bg-orange-100 text-orange-800'
: account.status === 'unauthorized'
? 'bg-red-100 text-red-800'
: account.status === 'temp_error'
? 'bg-orange-100 text-orange-800'
: account.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
]"
>
<div
:class="[
'mr-2 h-2 w-2 rounded-full',
account.status === 'blocked'
? 'bg-orange-500'
: account.status === 'unauthorized'
? 'bg-red-500'
: account.status === 'temp_error'
? 'bg-orange-500'
: account.isActive
? 'bg-green-500'
: 'bg-red-500'
]"
/>
{{
account.status === 'blocked'
? '已封锁'
: account.status === 'unauthorized'
? '异常'
: account.status === 'temp_error'
? '临时异常'
: account.isActive
? '正常'
: '异常'
}}
</span>
<span
v-if="
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
account.rateLimitStatus === 'limited'
"
class="inline-flex items-center rounded-full bg-yellow-100 px-3 py-1 text-xs font-semibold text-yellow-800"
>
<i class="fas fa-exclamation-triangle mr-1" />
限流中
<span
v-if="
account.rateLimitStatus &&
typeof account.rateLimitStatus === 'object' &&
account.rateLimitStatus.minutesRemaining > 0
"
>({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</span
>
</span>
<span
v-if="account.schedulable === false"
class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700"
>
<i class="fas fa-pause-circle mr-1" />
不可调度
<el-tooltip
v-if="getSchedulableReason(account)"
:content="getSchedulableReason(account)"
effect="dark"
placement="top"
>
<i class="fas fa-question-circle ml-1 cursor-help text-gray-500" />
</el-tooltip>
</span>
<span
v-if="account.status === 'blocked' && account.errorMessage"
class="mt-1 max-w-xs truncate text-xs text-gray-500 dark:text-gray-400"
:title="account.errorMessage"
>
{{ account.errorMessage }}
</span>
<span
v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500 dark:text-gray-400"
>
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div
v-if="
account.platform === 'claude' ||
account.platform === 'claude-console' ||
account.platform === 'bedrock' ||
account.platform === 'gemini' ||
account.platform === 'openai' ||
account.platform === 'openai-responses' ||
account.platform === 'azure_openai' ||
account.platform === 'ccr' ||
account.platform === 'droid'
"
class="flex items-center gap-2"
>
<div class="h-2 w-16 rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-gradient-to-r from-green-500 to-blue-600 transition-all duration-300"
:style="{ width: 101 - (account.priority || 50) + '%' }"
/>
</div>
<span class="min-w-[20px] text-xs font-medium text-gray-700 dark:text-gray-200">
{{ account.priority || 50 }}
</span>
</div>
<div v-else class="text-sm text-gray-400">
<span class="text-xs">N/A</span>
</div>
</td>
<td class="px-3 py-4 text-sm text-gray-600">
<div
v-if="formatProxyDisplay(account.proxy)"
class="break-all rounded bg-blue-50 px-2 py-1 font-mono text-xs"
:title="formatProxyDisplay(account.proxy)"
>
{{ formatProxyDisplay(account.proxy) }}
</div>
<div v-else class="text-gray-400">无代理</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-blue-500" />
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
>{{ account.usage.daily.requests || 0 }} 次</span
>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-purple-500" />
<span class="text-xs text-gray-600 dark:text-gray-300"
>{{ formatNumber(account.usage.daily.allTokens || 0) }}M</span
>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500" />
<span class="text-xs text-gray-600 dark:text-gray-300"
>${{ calculateDailyCost(account) }}</span
>
</div>
<div
v-if="account.usage.averages && account.usage.averages.rpm > 0"
class="text-xs text-gray-500 dark:text-gray-400"
>
平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM
</div>
</div>
<div v-else class="text-xs text-gray-400">暂无数据</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div v-if="account.platform === 'claude'" class="space-y-2">
<!-- 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"
/>
<!-- 账户过期时间编辑弹窗 -->
<AccountExpiryEditModal
ref="expiryEditModalRef"
:account="editingExpiryAccount || { id: null, expiresAt: null, name: '' }"
:show="!!editingExpiryAccount"
@close="closeAccountExpiryEdit"
@save="handleSaveAccountExpiry"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import { useConfirm } from '@/composables/useConfirm'
import AccountForm from '@/components/accounts/AccountForm.vue'
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue'
// 使用确认弹窗
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
// 数据状态
const accounts = ref([])
const accountsLoading = ref(false)
const accountSortBy = ref('name')
const accountsSortBy = ref('')
const accountsSortOrder = ref('asc')
const apiKeys = ref([])
const accountGroups = ref([])
const groupFilter = ref('all')
const platformFilter = ref('all')
const searchKeyword = ref('')
const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize'
const getInitialPageSize = () => {
const saved = localStorage.getItem(PAGE_SIZE_STORAGE_KEY)
if (saved) {
const parsedSize = parseInt(saved, 10)
if ([10, 20, 50, 100].includes(parsedSize)) {
return parsedSize
}
}
return 10
}
const pageSizeOptions = [10, 20, 50, 100]
const pageSize = ref(getInitialPageSize())
const currentPage = ref(1)
// 多选状态
const selectedAccounts = ref([])
const selectAllChecked = ref(false)
const isIndeterminate = ref(false)
const showCheckboxes = ref(false)
// 账号使用详情弹窗状态
const showAccountUsageModal = ref(false)
const accountUsageLoading = ref(false)
const selectedAccountForUsage = ref(null)
const accountUsageHistory = ref([])
const accountUsageSummary = ref({})
const accountUsageOverview = ref({})
const accountUsageGeneratedAt = ref('')
const supportedUsagePlatforms = [
'claude',
'claude-console',
'openai',
'openai-responses',
'gemini',
'droid'
]
// 过期时间编辑弹窗状态
const editingExpiryAccount = ref(null)
const expiryEditModalRef = ref(null)
// 缓存状态标志
const apiKeysLoaded = ref(false)
const groupsLoaded = ref(false)
const groupMembersLoaded = ref(false)
const accountGroupMap = ref(new Map()) // 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' : group.platform === 'openai' ? 'OpenAI' : 'Droid'})`,
icon:
group.platform === 'claude'
? 'fa-brain'
: group.platform === 'gemini'
? 'fa-robot'
: group.platform === 'openai'
? 'fa-openai'
: 'fa-robot'
})
})
return options
})
const shouldShowCheckboxes = computed(() => showCheckboxes.value)
// 模态框状态
const showCreateAccountModal = ref(false)
const newAccountPlatform = ref(null) // 跟踪新建账户选择的平台
const showEditAccountModal = ref(false)
const editingAccount = ref(null)
const collectAccountSearchableStrings = (account) => {
const values = new Set()
const baseFields = [
account?.name,
account?.email,
account?.accountName,
account?.owner,
account?.ownerName,
account?.ownerDisplayName,
account?.displayName,
account?.username,
account?.identifier,
account?.alias,
account?.title,
account?.label
]
baseFields.forEach((field) => {
if (typeof field === 'string') {
const trimmed = field.trim()
if (trimmed) {
values.add(trimmed)
}
}
})
if (Array.isArray(account?.groupInfos)) {
account.groupInfos.forEach((group) => {
if (group && typeof group.name === 'string') {
const trimmed = group.name.trim()
if (trimmed) {
values.add(trimmed)
}
}
})
}
Object.entries(account || {}).forEach(([key, value]) => {
if (typeof value === 'string') {
const lowerKey = key.toLowerCase()
if (lowerKey.includes('name') || lowerKey.includes('email')) {
const trimmed = value.trim()
if (trimmed) {
values.add(trimmed)
}
}
}
})
return Array.from(values)
}
const accountMatchesKeyword = (account, normalizedKeyword) => {
if (!normalizedKeyword) return true
return collectAccountSearchableStrings(account).some((value) =>
value.toLowerCase().includes(normalizedKeyword)
)
}
const canViewUsage = (account) => !!account && supportedUsagePlatforms.includes(account.platform)
const openAccountUsageModal = async (account) => {
if (!canViewUsage(account)) {
showToast('该账户类型暂不支持查看详情', 'warning')
return
}
selectedAccountForUsage.value = account
showAccountUsageModal.value = true
accountUsageLoading.value = true
accountUsageHistory.value = []
accountUsageSummary.value = {}
accountUsageOverview.value = {}
accountUsageGeneratedAt.value = ''
try {
const response = await apiClient.get(
`/admin/accounts/${account.id}/usage-history?platform=${account.platform}&days=30`
)
if (response.success) {
const data = response.data || {}
accountUsageHistory.value = data.history || []
accountUsageSummary.value = data.summary || {}
accountUsageOverview.value = data.overview || {}
accountUsageGeneratedAt.value = data.generatedAt || ''
} else {
showToast(response.error || '加载账号使用详情失败', 'error')
}
} catch (error) {
showToast('加载账号使用详情失败', 'error')
} finally {
accountUsageLoading.value = false
}
}
const closeAccountUsageModal = () => {
showAccountUsageModal.value = false
accountUsageLoading.value = false
selectedAccountForUsage.value = null
}
// 计算排序后的账户列表
const sortedAccounts = computed(() => {
let sourceAccounts = accounts.value
const keyword = searchKeyword.value.trim()
if (keyword) {
const normalizedKeyword = keyword.toLowerCase()
sourceAccounts = sourceAccounts.filter((account) =>
accountMatchesKeyword(account, normalizedKeyword)
)
}
if (!accountsSortBy.value) return sourceAccounts
const sorted = [...sourceAccounts].sort((a, b) => {
let aVal = a[accountsSortBy.value]
let bVal = b[accountsSortBy.value]
// 处理统计数据
if (accountsSortBy.value === 'dailyTokens') {
aVal = a.usage?.daily?.allTokens || 0
bVal = b.usage?.daily?.allTokens || 0
} else if (accountsSortBy.value === 'dailyRequests') {
aVal = a.usage?.daily?.requests || 0
bVal = b.usage?.daily?.requests || 0
} else if (accountsSortBy.value === 'totalTokens') {
aVal = a.usage?.total?.allTokens || 0
bVal = b.usage?.total?.allTokens || 0
}
// 处理最后使用时间
if (accountsSortBy.value === 'lastUsed') {
aVal = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
bVal = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
}
// 处理状态
if (accountsSortBy.value === 'status') {
aVal = a.isActive ? 1 : 0
bVal = b.isActive ? 1 : 0
}
if (aVal < bVal) return accountsSortOrder.value === 'asc' ? -1 : 1
if (aVal > bVal) return accountsSortOrder.value === 'asc' ? 1 : -1
return 0
})
return sorted
})
const totalPages = computed(() => {
const total = sortedAccounts.value.length
return Math.ceil(total / pageSize.value) || 0
})
const pageNumbers = computed(() => {
const total = totalPages.value
const current = currentPage.value
const pages = []
if (total <= 7) {
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
let start = Math.max(1, current - 2)
let end = Math.min(total, current + 2)
if (current <= 3) {
end = 5
} else if (current >= total - 2) {
start = total - 4
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
}
return pages
})
const shouldShowFirstPage = computed(() => {
const pages = pageNumbers.value
if (pages.length === 0) return false
return pages[0] > 1
})
const shouldShowLastPage = computed(() => {
const pages = pageNumbers.value
if (pages.length === 0) return false
return pages[pages.length - 1] < totalPages.value
})
const showLeadingEllipsis = computed(() => {
const pages = pageNumbers.value
if (pages.length === 0) return false
return shouldShowFirstPage.value && pages[0] > 2
})
const showTrailingEllipsis = computed(() => {
const pages = pageNumbers.value
if (pages.length === 0) return false
return shouldShowLastPage.value && pages[pages.length - 1] < totalPages.value - 1
})
const paginatedAccounts = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return sortedAccounts.value.slice(start, end)
})
const updateSelectAllState = () => {
const currentIds = paginatedAccounts.value.map((account) => account.id)
const selectedInCurrentPage = currentIds.filter((id) =>
selectedAccounts.value.includes(id)
).length
const totalInCurrentPage = currentIds.length
if (selectedInCurrentPage === 0) {
selectAllChecked.value = false
isIndeterminate.value = false
} else if (selectedInCurrentPage === totalInCurrentPage) {
selectAllChecked.value = true
isIndeterminate.value = false
} else {
selectAllChecked.value = false
isIndeterminate.value = true
}
}
const handleSelectAll = () => {
if (selectAllChecked.value) {
paginatedAccounts.value.forEach((account) => {
if (!selectedAccounts.value.includes(account.id)) {
selectedAccounts.value.push(account.id)
}
})
} else {
const currentIds = new Set(paginatedAccounts.value.map((account) => account.id))
selectedAccounts.value = selectedAccounts.value.filter((id) => !currentIds.has(id))
}
updateSelectAllState()
}
const toggleSelectionMode = () => {
showCheckboxes.value = !showCheckboxes.value
if (!showCheckboxes.value) {
selectedAccounts.value = []
selectAllChecked.value = false
isIndeterminate.value = false
} else {
updateSelectAllState()
}
}
const cleanupSelectedAccounts = () => {
const validIds = new Set(accounts.value.map((account) => account.id))
selectedAccounts.value = selectedAccounts.value.filter((id) => validIds.has(id))
updateSelectAllState()
}
// 加载账户列表
const loadAccounts = async (forceReload = false) => {
accountsLoading.value = true
try {
// 构建查询参数(用于其他筛选情况)
const params = {}
if (platformFilter.value !== 'all') {
params.platform = platformFilter.value
}
if (groupFilter.value !== 'all') {
params.groupId = groupFilter.value
}
// 根据平台筛选决定需要请求哪些接口
const requests = []
if (platformFilter.value === 'all') {
// 请求所有平台
requests.push(
apiClient.get('/admin/claude-accounts', { params }),
apiClient.get('/admin/claude-console-accounts', { params }),
apiClient.get('/admin/bedrock-accounts', { params }),
apiClient.get('/admin/gemini-accounts', { params }),
apiClient.get('/admin/openai-accounts', { params }),
apiClient.get('/admin/azure-openai-accounts', { params }),
apiClient.get('/admin/openai-responses-accounts', { params }),
apiClient.get('/admin/ccr-accounts', { params }),
apiClient.get('/admin/droid-accounts', { params })
)
} else {
// 只请求指定平台,其他平台设为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) => {
return {
...acc,
platform: 'droid',
boundApiKeysCount: acc.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)
})
}
}
filteredAccounts = filteredAccounts.map((account) => {
const proxyConfig = normalizeProxyData(account.proxyConfig || account.proxy)
return {
...account,
proxyConfig: proxyConfig || null
}
})
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()
}
// 规范化代理配置,支持字符串与对象
function normalizeProxyData(proxy) {
if (!proxy) {
return null
}
let proxyObject = proxy
if (typeof proxy === 'string') {
try {
proxyObject = JSON.parse(proxy)
} catch (error) {
return null
}
}
if (!proxyObject || typeof proxyObject !== 'object') {
return null
}
const candidate =
proxyObject.proxy && typeof proxyObject.proxy === 'object' ? proxyObject.proxy : proxyObject
const host =
typeof candidate.host === 'string'
? candidate.host.trim()
: candidate.host !== undefined && candidate.host !== null
? String(candidate.host).trim()
: ''
const port =
candidate.port !== undefined && candidate.port !== null ? String(candidate.port).trim() : ''
if (!host || !port) {
return null
}
const type =
typeof candidate.type === 'string' && candidate.type.trim() ? candidate.type.trim() : 'socks5'
const username =
typeof candidate.username === 'string'
? candidate.username
: candidate.username !== undefined && candidate.username !== null
? String(candidate.username)
: ''
const password =
typeof candidate.password === 'string'
? candidate.password
: candidate.password !== undefined && candidate.password !== null
? String(candidate.password)
: ''
return {
type,
host,
port,
username,
password
}
}
// 格式化代理信息显示
const formatProxyDisplay = (proxy) => {
const parsed = normalizeProxyData(proxy)
if (!parsed) {
return null
}
const typeShort = parsed.type.toLowerCase() === 'socks5' ? 'S5' : parsed.type.toUpperCase()
let host = parsed.host
if (host.length > 15) {
host = host.substring(0, 12) + '...'
}
let display = `${typeShort}://${host}:${parsed.port}`
if (parsed.username) {
display = `${typeShort}://***@${host}:${parsed.port}`
}
return display
}
// 格式化会话窗口时间
const formatSessionWindow = (windowStart, windowEnd) => {
if (!windowStart || !windowEnd) return '--'
const start = new Date(windowStart)
const end = new Date(windowEnd)
const startHour = start.getHours().toString().padStart(2, '0')
const startMin = start.getMinutes().toString().padStart(2, '0')
const endHour = end.getHours().toString().padStart(2, '0')
const endMin = end.getMinutes().toString().padStart(2, '0')
return `${startHour}:${startMin} - ${endHour}:${endMin}`
}
// 格式化剩余时间
const formatRemainingTime = (minutes) => {
if (!minutes || minutes <= 0) return '已结束'
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
if (hours > 0) {
return `${hours}小时${mins}分钟`
}
return `${mins}分钟`
}
// 格式化限流时间(支持显示天数)
const formatRateLimitTime = (minutes) => {
if (!minutes || minutes <= 0) return ''
// 转换为整数,避免小数
minutes = Math.floor(minutes)
// 计算天数、小时和分钟
const days = Math.floor(minutes / 1440) // 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'
}
// 获取 Droid 账号的认证方式
const getDroidAuthType = (account) => {
if (!account || typeof account !== 'object') {
return 'OAuth'
}
const apiKeyModeFlag =
account.isApiKeyMode ?? account.is_api_key_mode ?? account.apiKeyMode ?? account.api_key_mode
if (
apiKeyModeFlag === true ||
apiKeyModeFlag === 'true' ||
apiKeyModeFlag === 1 ||
apiKeyModeFlag === '1'
) {
return 'API Key'
}
const methodCandidate =
account.authenticationMethod ||
account.authMethod ||
account.authentication_mode ||
account.authenticationMode ||
account.authentication_method ||
account.auth_type ||
account.authType ||
account.authentication_type ||
account.authenticationType ||
account.droidAuthType ||
account.droidAuthenticationMethod ||
account.method ||
account.auth ||
''
if (typeof methodCandidate === 'string') {
const normalized = methodCandidate.trim().toLowerCase()
const compacted = normalized.replace(/[\s_-]/g, '')
if (compacted === 'apikey') {
return 'API Key'
}
}
return 'OAuth'
}
// 判断是否为 API Key 模式的 Droid 账号
const isDroidApiKeyMode = (account) => getDroidAuthType(account) === 'API Key'
// 获取 Droid 账号的 API Key 数量
const getDroidApiKeyCount = (account) => {
if (!account || typeof account !== 'object') {
return 0
}
// 优先使用 apiKeys 数组来计算正常状态的 API Keys
if (Array.isArray(account.apiKeys)) {
// 只计算状态不是 'error' 的 API Keys
return account.apiKeys.filter((apiKey) => apiKey.status !== 'error').length
}
// 如果是字符串格式的 apiKeys,尝试解析
if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) {
try {
const parsed = JSON.parse(account.apiKeys)
if (Array.isArray(parsed)) {
// 只计算状态不是 'error' 的 API Keys
return parsed.filter((apiKey) => apiKey.status !== 'error').length
}
} catch (error) {
// 忽略解析错误,继续使用其他字段
}
}
const candidates = [
account.apiKeyCount,
account.api_key_count,
account.apiKeysCount,
account.api_keys_count
]
for (const candidate of candidates) {
const value = Number(candidate)
if (Number.isFinite(value) && value >= 0) {
return value
}
}
return 0
}
// 根据数量返回徽标样式
const getDroidApiKeyBadgeClasses = (account) => {
const count = getDroidApiKeyCount(account)
const baseClass =
'ml-1 inline-flex items-center gap-1 rounded-md border px-1.5 py-[1px] text-[10px] font-medium shadow-sm backdrop-blur-sm'
if (count > 0) {
return [
baseClass,
'border-cyan-200 bg-cyan-50/90 text-cyan-700 dark:border-cyan-500/40 dark:bg-cyan-900/40 dark:text-cyan-200'
]
}
return [
baseClass,
'border-rose-200 bg-rose-50/90 text-rose-600 dark:border-rose-500/40 dark:bg-rose-900/40 dark:text-rose-200'
]
}
// 获取 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()
})
// 到期时间相关方法
const formatExpireDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
const isExpired = (expiresAt) => {
if (!expiresAt) return false
return new Date(expiresAt) < new Date()
}
const isExpiringSoon = (expiresAt) => {
if (!expiresAt) return false
const now = new Date()
const expireDate = new Date(expiresAt)
const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24)
return daysUntilExpire > 0 && daysUntilExpire <= 7
}
// 开始编辑账户过期时间
const startEditAccountExpiry = (account) => {
editingExpiryAccount.value = account
}
// 关闭账户过期时间编辑
const closeAccountExpiryEdit = () => {
editingExpiryAccount.value = null
}
// 保存账户过期时间
const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
try {
// 根据账号平台选择正确的 API 端点
const account = accounts.value.find((acc) => acc.id === accountId)
if (!account) {
showToast('未找到账户', 'error')
return
}
// 定义每个平台的端点和参数名
// 注意:部分平台使用 :accountId,部分使用 :id
let endpoint = ''
switch (account.platform) {
case 'claude':
case 'claude-oauth':
endpoint = `/admin/claude-accounts/${accountId}`
break
case 'gemini':
endpoint = `/admin/gemini-accounts/${accountId}`
break
case 'claude-console':
endpoint = `/admin/claude-console-accounts/${accountId}`
break
case 'bedrock':
endpoint = `/admin/bedrock-accounts/${accountId}`
break
case 'ccr':
endpoint = `/admin/ccr-accounts/${accountId}`
break
case 'openai':
endpoint = `/admin/openai-accounts/${accountId}` // 使用 :id
break
case 'droid':
endpoint = `/admin/droid-accounts/${accountId}` // 使用 :id
break
case 'azure_openai':
endpoint = `/admin/azure-openai-accounts/${accountId}` // 使用 :id
break
case 'openai-responses':
endpoint = `/admin/openai-responses-accounts/${accountId}` // 使用 :id
break
default:
showToast(`不支持的平台类型: ${account.platform}`, 'error')
return
}
const data = await apiClient.put(endpoint, {
expiresAt: expiresAt || null
})
if (data.success) {
showToast('账户到期时间已更新', 'success')
// 更新本地数据
account.expiresAt = expiresAt || null
closeAccountExpiryEdit()
} else {
showToast(data.message || '更新失败', 'error')
// 重置保存状态
if (expiryEditModalRef.value) {
expiryEditModalRef.value.resetSaving()
}
}
} catch (error) {
console.error('更新账户过期时间失败:', error)
showToast('更新失败', 'error')
// 重置保存状态
if (expiryEditModalRef.value) {
expiryEditModalRef.value.resetSaving()
}
}
}
onMounted(() => {
// 首次加载时强制刷新所有数据
loadAccounts(true)
})
</script>
<style scoped>
.table-container {
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e5e7eb;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.accounts-container {
min-height: calc(100vh - 300px);
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
</style>