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