mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
3481 lines
137 KiB
Vue
3481 lines
137 KiB
Vue
<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-[100px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||
@click="sortAccounts('status')"
|
||
>
|
||
状态
|
||
<i
|
||
v-if="accountsSortBy === 'status'"
|
||
:class="[
|
||
'fas',
|
||
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
|
||
'ml-1'
|
||
]"
|
||
/>
|
||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||
</th>
|
||
<th
|
||
class="w-[8%] min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||
@click="sortAccounts('priority')"
|
||
>
|
||
优先级
|
||
<i
|
||
v-if="accountsSortBy === 'priority'"
|
||
:class="[
|
||
'fas',
|
||
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
|
||
'ml-1'
|
||
]"
|
||
/>
|
||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||
</th>
|
||
<th
|
||
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||
>
|
||
代理
|
||
</th>
|
||
<th
|
||
class="w-[10%] min-w-[90px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||
>
|
||
今日使用
|
||
</th>
|
||
<th
|
||
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<span>会话窗口</span>
|
||
<el-tooltip placement="top">
|
||
<template #content>
|
||
<div
|
||
class="w-[260px] space-y-3 text-xs leading-relaxed text-white dark:text-gray-800"
|
||
>
|
||
<div class="space-y-2">
|
||
<div class="text-sm font-semibold text-white dark:text-gray-900">
|
||
Claude 系列
|
||
</div>
|
||
<div class="text-gray-200 dark:text-gray-600">
|
||
会话窗口进度表示 5 小时窗口的时间推移,颜色提示当前调度状态。
|
||
</div>
|
||
<div class="space-y-1 pt-1 text-gray-200 dark:text-gray-600">
|
||
<div class="flex items-center gap-2">
|
||
<div
|
||
class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600"
|
||
></div>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>正常:请求正常处理</span
|
||
>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<div
|
||
class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500"
|
||
></div>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>警告:接近限制</span
|
||
>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<div
|
||
class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600"
|
||
></div>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>拒绝:达到速率限制</span
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="h-px bg-gray-200 dark:bg-gray-600/50"></div>
|
||
<div class="space-y-2">
|
||
<div class="text-sm font-semibold text-white dark:text-gray-900">
|
||
OpenAI
|
||
</div>
|
||
<div class="text-gray-200 dark:text-gray-600">
|
||
进度条分别展示 5h 与周限窗口的额度使用比例,颜色含义与上方保持一致。
|
||
</div>
|
||
<div class="space-y-1 text-gray-200 dark:text-gray-600">
|
||
<div class="flex items-start gap-2">
|
||
<i class="fas fa-clock mt-[2px] text-[10px] text-blue-500"></i>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>5h 窗口:5小时使用量进度,到达重置时间后会自动归零。</span
|
||
>
|
||
</div>
|
||
<div class="flex items-start gap-2">
|
||
<i class="fas fa-history mt-[2px] text-[10px] text-emerald-500"></i>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>周限窗口:7天使用量进度,重置时同样回到 0%。</span
|
||
>
|
||
</div>
|
||
<div class="flex items-start gap-2">
|
||
<i
|
||
class="fas fa-info-circle mt-[2px] text-[10px] text-indigo-500"
|
||
></i>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>当“重置剩余”为 0 时,进度条与百分比会同步清零。</span
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="h-px bg-gray-200 dark:bg-gray-600/50"></div>
|
||
<div class="space-y-2">
|
||
<div class="text-sm font-semibold text-white dark:text-gray-900">
|
||
Claude OAuth 账户
|
||
</div>
|
||
<div class="text-gray-200 dark:text-gray-600">
|
||
展示三个窗口的使用率(utilization百分比),颜色含义同上。
|
||
</div>
|
||
<div class="space-y-1 text-gray-200 dark:text-gray-600">
|
||
<div class="flex items-start gap-2">
|
||
<i class="fas fa-clock mt-[2px] text-[10px] text-indigo-500"></i>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>5h 窗口:5小时滑动窗口的使用率。</span
|
||
>
|
||
</div>
|
||
<div class="flex items-start gap-2">
|
||
<i
|
||
class="fas fa-calendar-alt mt-[2px] text-[10px] text-emerald-500"
|
||
></i>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>7d 窗口:7天总限额的使用率。</span
|
||
>
|
||
</div>
|
||
<div class="flex items-start gap-2">
|
||
<i class="fas fa-gem mt-[2px] text-[10px] text-purple-500"></i>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>Opus 窗口:7天Opus模型专用限额。</span
|
||
>
|
||
</div>
|
||
<div class="flex items-start gap-2">
|
||
<i class="fas fa-sync-alt mt-[2px] text-[10px] text-blue-500"></i>
|
||
<span class="font-medium text-white dark:text-gray-900"
|
||
>到达重置时间后自动归零。</span
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<i
|
||
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
|
||
/>
|
||
</el-tooltip>
|
||
</div>
|
||
</th>
|
||
<th
|
||
class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||
>
|
||
最后使用
|
||
</th>
|
||
<th
|
||
class="w-[15%] min-w-[180px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||
>
|
||
操作
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
|
||
<tr v-for="account in paginatedAccounts" :key="account.id" class="table-row">
|
||
<td v-if="shouldShowCheckboxes" class="px-3 py-3">
|
||
<div class="flex items-center">
|
||
<input
|
||
v-model="selectedAccounts"
|
||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
type="checkbox"
|
||
:value="account.id"
|
||
@change="updateSelectAllState"
|
||
/>
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-4">
|
||
<div class="flex items-center">
|
||
<div
|
||
class="mr-2 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-green-500 to-green-600"
|
||
>
|
||
<i class="fas fa-user-circle text-xs text-white" />
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="flex items-center gap-2">
|
||
<div
|
||
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
|
||
:title="account.name"
|
||
>
|
||
{{ account.name }}
|
||
</div>
|
||
<span
|
||
v-if="account.accountType === 'dedicated'"
|
||
class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800"
|
||
>
|
||
<i class="fas fa-lock mr-1" />专属
|
||
</span>
|
||
<span
|
||
v-else-if="account.accountType === 'group'"
|
||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
||
>
|
||
<i class="fas fa-layer-group mr-1" />分组调度
|
||
</span>
|
||
<span
|
||
v-else
|
||
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
|
||
>
|
||
<i class="fas fa-share-alt mr-1" />共享
|
||
</span>
|
||
</div>
|
||
<!-- 显示所有分组 - 换行显示 -->
|
||
<div
|
||
v-if="account.groupInfos && account.groupInfos.length > 0"
|
||
class="my-2 flex flex-wrap items-center gap-2"
|
||
>
|
||
<span
|
||
v-for="group in account.groupInfos"
|
||
:key="group.id"
|
||
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||
:title="`所属分组: ${group.name}`"
|
||
>
|
||
<i class="fas fa-folder mr-1" />{{ group.name }}
|
||
</span>
|
||
</div>
|
||
<div
|
||
class="truncate text-xs text-gray-500 dark:text-gray-400"
|
||
:title="account.id"
|
||
>
|
||
{{ account.id }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-4">
|
||
<div class="flex items-center gap-1">
|
||
<!-- 平台图标和名称 -->
|
||
<div
|
||
v-if="account.platform === 'gemini'"
|
||
class="flex items-center gap-1.5 rounded-lg border border-yellow-200 bg-gradient-to-r from-yellow-100 to-amber-100 px-2.5 py-1"
|
||
>
|
||
<i class="fas fa-robot text-xs text-yellow-700" />
|
||
<span class="text-xs font-semibold text-yellow-800">Gemini</span>
|
||
<span class="mx-1 h-4 w-px bg-yellow-300" />
|
||
<span class="text-xs font-medium text-yellow-700">
|
||
{{ getGeminiAuthType() }}
|
||
</span>
|
||
</div>
|
||
<div
|
||
v-else-if="account.platform === 'claude-console'"
|
||
class="flex items-center gap-1.5 rounded-lg border border-purple-200 bg-gradient-to-r from-purple-100 to-pink-100 px-2.5 py-1"
|
||
>
|
||
<i class="fas fa-terminal text-xs text-purple-700" />
|
||
<span class="text-xs font-semibold text-purple-800">Console</span>
|
||
<span class="mx-1 h-4 w-px bg-purple-300" />
|
||
<span class="text-xs font-medium text-purple-700">API Key</span>
|
||
</div>
|
||
<div
|
||
v-else-if="account.platform === 'bedrock'"
|
||
class="flex items-center gap-1.5 rounded-lg border border-orange-200 bg-gradient-to-r from-orange-100 to-red-100 px-2.5 py-1"
|
||
>
|
||
<i class="fab fa-aws text-xs text-orange-700" />
|
||
<span class="text-xs font-semibold text-orange-800">Bedrock</span>
|
||
<span class="mx-1 h-4 w-px bg-orange-300" />
|
||
<span class="text-xs font-medium text-orange-700">AWS</span>
|
||
</div>
|
||
<div
|
||
v-else-if="account.platform === 'openai'"
|
||
class="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-100 bg-gradient-to-r from-gray-100 to-gray-100 px-2.5 py-1"
|
||
>
|
||
<div class="fa-openai" />
|
||
<span class="text-xs font-semibold text-gray-950">OpenAi</span>
|
||
<span class="mx-1 h-4 w-px bg-gray-400" />
|
||
<span class="text-xs font-medium text-gray-950">{{ getOpenAIAuthType() }}</span>
|
||
</div>
|
||
<div
|
||
v-else-if="account.platform === 'azure_openai'"
|
||
class="flex items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-100 to-cyan-100 px-2.5 py-1 dark:border-blue-700 dark:from-blue-900/20 dark:to-cyan-900/20"
|
||
>
|
||
<i class="fab fa-microsoft text-xs text-blue-700 dark:text-blue-400" />
|
||
<span class="text-xs font-semibold text-blue-800 dark:text-blue-300"
|
||
>Azure OpenAI</span
|
||
>
|
||
<span class="mx-1 h-4 w-px bg-blue-300 dark:bg-blue-600" />
|
||
<span class="text-xs font-medium text-blue-700 dark:text-blue-400"
|
||
>API Key</span
|
||
>
|
||
</div>
|
||
<div
|
||
v-else-if="account.platform === 'openai-responses'"
|
||
class="flex items-center gap-1.5 rounded-lg border border-teal-200 bg-gradient-to-r from-teal-100 to-green-100 px-2.5 py-1 dark:border-teal-700 dark:from-teal-900/20 dark:to-green-900/20"
|
||
>
|
||
<i class="fas fa-server text-xs text-teal-700 dark:text-teal-400" />
|
||
<span class="text-xs font-semibold text-teal-800 dark:text-teal-300"
|
||
>OpenAI-Responses</span
|
||
>
|
||
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" />
|
||
<span class="text-xs font-medium text-teal-700 dark:text-teal-400"
|
||
>API Key</span
|
||
>
|
||
</div>
|
||
<div
|
||
v-else-if="account.platform === 'claude' || account.platform === 'claude-oauth'"
|
||
class="flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100 px-2.5 py-1"
|
||
>
|
||
<i class="fas fa-brain text-xs text-indigo-700" />
|
||
<span class="text-xs font-semibold text-indigo-800">{{
|
||
getClaudeAccountType(account)
|
||
}}</span>
|
||
<span class="mx-1 h-4 w-px bg-indigo-300" />
|
||
<span class="text-xs font-medium text-indigo-700">
|
||
{{ getClaudeAuthType(account) }}
|
||
</span>
|
||
</div>
|
||
<div
|
||
v-else-if="account.platform === 'ccr'"
|
||
class="flex items-center gap-1.5 rounded-lg border border-teal-200 bg-gradient-to-r from-teal-100 to-emerald-100 px-2.5 py-1 dark:border-teal-700 dark:from-teal-900/20 dark:to-emerald-900/20"
|
||
>
|
||
<i class="fas fa-code-branch text-xs text-teal-700 dark:text-teal-400" />
|
||
<span class="text-xs font-semibold text-teal-800 dark:text-teal-300">CCR</span>
|
||
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" />
|
||
<span class="text-xs font-medium text-teal-700 dark:text-teal-300">Relay</span>
|
||
</div>
|
||
<div
|
||
v-else-if="account.platform === 'droid'"
|
||
class="flex items-center gap-1.5 rounded-lg border border-cyan-200 bg-gradient-to-r from-cyan-100 to-sky-100 px-2.5 py-1 dark:border-cyan-700 dark:from-cyan-900/20 dark:to-sky-900/20"
|
||
>
|
||
<i class="fas fa-robot text-xs text-cyan-700 dark:text-cyan-400" />
|
||
<span class="text-xs font-semibold text-cyan-800 dark:text-cyan-300"
|
||
>Droid</span
|
||
>
|
||
<span class="mx-1 h-4 w-px bg-cyan-300 dark:bg-cyan-600" />
|
||
<span class="text-xs font-medium text-cyan-700 dark:text-cyan-300">OAuth</span>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1"
|
||
>
|
||
<i class="fas fa-question text-xs text-gray-700" />
|
||
<span class="text-xs font-semibold text-gray-800">未知</span>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="whitespace-nowrap px-3 py-4">
|
||
<div class="flex flex-col gap-1">
|
||
<span
|
||
:class="[
|
||
'inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold',
|
||
account.status === 'blocked'
|
||
? 'bg-orange-100 text-orange-800'
|
||
: account.status === 'unauthorized'
|
||
? 'bg-red-100 text-red-800'
|
||
: account.status === 'temp_error'
|
||
? 'bg-orange-100 text-orange-800'
|
||
: account.isActive
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-red-100 text-red-800'
|
||
]"
|
||
>
|
||
<div
|
||
:class="[
|
||
'mr-2 h-2 w-2 rounded-full',
|
||
account.status === 'blocked'
|
||
? 'bg-orange-500'
|
||
: account.status === 'unauthorized'
|
||
? 'bg-red-500'
|
||
: account.status === 'temp_error'
|
||
? 'bg-orange-500'
|
||
: account.isActive
|
||
? 'bg-green-500'
|
||
: 'bg-red-500'
|
||
]"
|
||
/>
|
||
{{
|
||
account.status === 'blocked'
|
||
? '已封锁'
|
||
: account.status === 'unauthorized'
|
||
? '异常'
|
||
: account.status === 'temp_error'
|
||
? '临时异常'
|
||
: account.isActive
|
||
? '正常'
|
||
: '异常'
|
||
}}
|
||
</span>
|
||
<span
|
||
v-if="
|
||
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
|
||
account.rateLimitStatus === 'limited'
|
||
"
|
||
class="inline-flex items-center rounded-full bg-yellow-100 px-3 py-1 text-xs font-semibold text-yellow-800"
|
||
>
|
||
<i class="fas fa-exclamation-triangle mr-1" />
|
||
限流中
|
||
<span
|
||
v-if="
|
||
account.rateLimitStatus &&
|
||
typeof account.rateLimitStatus === 'object' &&
|
||
account.rateLimitStatus.minutesRemaining > 0
|
||
"
|
||
>({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</span
|
||
>
|
||
</span>
|
||
<span
|
||
v-if="account.schedulable === false"
|
||
class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700"
|
||
>
|
||
<i class="fas fa-pause-circle mr-1" />
|
||
不可调度
|
||
<el-tooltip
|
||
v-if="getSchedulableReason(account)"
|
||
:content="getSchedulableReason(account)"
|
||
effect="dark"
|
||
placement="top"
|
||
>
|
||
<i class="fas fa-question-circle ml-1 cursor-help text-gray-500" />
|
||
</el-tooltip>
|
||
</span>
|
||
<span
|
||
v-if="account.status === 'blocked' && account.errorMessage"
|
||
class="mt-1 max-w-xs truncate text-xs text-gray-500 dark:text-gray-400"
|
||
:title="account.errorMessage"
|
||
>
|
||
{{ account.errorMessage }}
|
||
</span>
|
||
<span
|
||
v-if="account.accountType === 'dedicated'"
|
||
class="text-xs text-gray-500 dark:text-gray-400"
|
||
>
|
||
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td class="whitespace-nowrap px-3 py-4">
|
||
<div
|
||
v-if="
|
||
account.platform === 'claude' ||
|
||
account.platform === 'claude-console' ||
|
||
account.platform === 'bedrock' ||
|
||
account.platform === 'gemini' ||
|
||
account.platform === 'openai' ||
|
||
account.platform === 'openai-responses' ||
|
||
account.platform === 'azure_openai' ||
|
||
account.platform === 'ccr' ||
|
||
account.platform === 'droid'
|
||
"
|
||
class="flex items-center gap-2"
|
||
>
|
||
<div class="h-2 w-16 rounded-full bg-gray-200">
|
||
<div
|
||
class="h-2 rounded-full bg-gradient-to-r from-green-500 to-blue-600 transition-all duration-300"
|
||
:style="{ width: 101 - (account.priority || 50) + '%' }"
|
||
/>
|
||
</div>
|
||
<span class="min-w-[20px] text-xs font-medium text-gray-700 dark:text-gray-200">
|
||
{{ account.priority || 50 }}
|
||
</span>
|
||
</div>
|
||
<div v-else class="text-sm text-gray-400">
|
||
<span class="text-xs">N/A</span>
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-4 text-sm text-gray-600">
|
||
<div
|
||
v-if="formatProxyDisplay(account.proxy)"
|
||
class="break-all rounded bg-blue-50 px-2 py-1 font-mono text-xs"
|
||
:title="formatProxyDisplay(account.proxy)"
|
||
>
|
||
{{ formatProxyDisplay(account.proxy) }}
|
||
</div>
|
||
<div v-else class="text-gray-400">无代理</div>
|
||
</td>
|
||
<td class="whitespace-nowrap px-3 py-4 text-sm">
|
||
<div v-if="account.usage && account.usage.daily" class="space-y-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 w-2 rounded-full bg-blue-500" />
|
||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||
>{{ account.usage.daily.requests || 0 }} 次</span
|
||
>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 w-2 rounded-full bg-purple-500" />
|
||
<span class="text-xs text-gray-600 dark:text-gray-300"
|
||
>{{ formatNumber(account.usage.daily.allTokens || 0) }}M</span
|
||
>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 w-2 rounded-full bg-green-500" />
|
||
<span class="text-xs text-gray-600 dark:text-gray-300"
|
||
>${{ calculateDailyCost(account) }}</span
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="account.usage.averages && account.usage.averages.rpm > 0"
|
||
class="text-xs text-gray-500 dark:text-gray-400"
|
||
>
|
||
平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
||
</td>
|
||
<td class="whitespace-nowrap px-3 py-4">
|
||
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||
<div v-if="isClaudeOAuth(account) && account.claudeUsage" class="space-y-2">
|
||
<!-- 5小时窗口 -->
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300"
|
||
>
|
||
5h
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getClaudeUsageBarClass(account.claudeUsage.fiveHour)
|
||
]"
|
||
:style="{
|
||
width: getClaudeUsageWidth(account.claudeUsage.fiveHour)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatClaudeUsagePercent(account.claudeUsage.fiveHour) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.fiveHour) }}
|
||
</div>
|
||
</div>
|
||
<!-- 7天窗口 -->
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300"
|
||
>
|
||
7d
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getClaudeUsageBarClass(account.claudeUsage.sevenDay)
|
||
]"
|
||
:style="{
|
||
width: getClaudeUsageWidth(account.claudeUsage.sevenDay)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDay) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDay) }}
|
||
</div>
|
||
</div>
|
||
<!-- 7天Opus窗口 -->
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-purple-100 px-2 py-0.5 text-[11px] font-medium text-purple-600 dark:bg-purple-500/20 dark:text-purple-300"
|
||
>
|
||
Opus
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getClaudeUsageBarClass(account.claudeUsage.sevenDayOpus)
|
||
]"
|
||
:style="{
|
||
width: getClaudeUsageWidth(account.claudeUsage.sevenDayOpus)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDayOpus) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDayOpus) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Setup Token 账户:显示原有的会话窗口时间进度 -->
|
||
<div
|
||
v-else-if="
|
||
!isClaudeOAuth(account) &&
|
||
account.sessionWindow &&
|
||
account.sessionWindow.hasActiveWindow
|
||
"
|
||
class="space-y-2"
|
||
>
|
||
<!-- 使用统计在顶部 -->
|
||
<div
|
||
v-if="account.usage && account.usage.sessionWindow"
|
||
class="flex items-center gap-3 text-xs"
|
||
>
|
||
<div class="flex items-center gap-1">
|
||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-1">
|
||
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||
${{ formatCost(account.usage.sessionWindow.totalCost) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 进度条 -->
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getSessionProgressBarClass(
|
||
account.sessionWindow.sessionWindowStatus,
|
||
account
|
||
)
|
||
]"
|
||
:style="{ width: account.sessionWindow.progress + '%' }"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200"
|
||
>
|
||
{{ account.sessionWindow.progress }}%
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 时间信息 -->
|
||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||
<div>
|
||
{{
|
||
formatSessionWindow(
|
||
account.sessionWindow.windowStart,
|
||
account.sessionWindow.windowEnd
|
||
)
|
||
}}
|
||
</div>
|
||
<div
|
||
v-if="account.sessionWindow.remainingTime > 0"
|
||
class="font-medium text-indigo-600 dark:text-indigo-400"
|
||
>
|
||
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-xs text-gray-400">暂无统计</div>
|
||
</div>
|
||
<!-- Claude Console: 显示每日额度使用进度 -->
|
||
<div v-else-if="account.platform === 'claude-console'" class="space-y-2">
|
||
<div v-if="Number(account.dailyQuota) > 0">
|
||
<div class="flex items-center justify-between text-xs">
|
||
<span class="text-gray-600 dark:text-gray-300">额度进度</span>
|
||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||
{{ getQuotaUsagePercent(account).toFixed(1) }}%
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getQuotaBarClass(getQuotaUsagePercent(account))
|
||
]"
|
||
:style="{ width: Math.min(100, getQuotaUsagePercent(account)) + '%' }"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200"
|
||
>
|
||
${{ formatCost(account.usage?.daily?.cost || 0) }} / ${{
|
||
Number(account.dailyQuota).toFixed(2)
|
||
}}
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||
剩余 ${{ formatRemainingQuota(account) }}
|
||
<span class="ml-2 text-gray-400"
|
||
>重置 {{ account.quotaResetTime || '00:00' }}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-sm text-gray-400">
|
||
<i class="fas fa-minus" />
|
||
</div>
|
||
</div>
|
||
<div v-else-if="account.platform === 'openai'" class="space-y-2">
|
||
<div v-if="account.codexUsage" class="space-y-2">
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300"
|
||
>
|
||
{{ getCodexWindowLabel('primary') }}
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getCodexUsageBarClass(account.codexUsage.primary)
|
||
]"
|
||
:style="{
|
||
width: getCodexUsageWidth(account.codexUsage.primary)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatCodexUsagePercent(account.codexUsage.primary) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }}
|
||
</div>
|
||
</div>
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-600 dark:bg-blue-500/20 dark:text-blue-300"
|
||
>
|
||
{{ getCodexWindowLabel('secondary') }}
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getCodexUsageBarClass(account.codexUsage.secondary)
|
||
]"
|
||
:style="{
|
||
width: getCodexUsageWidth(account.codexUsage.secondary)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatCodexUsagePercent(account.codexUsage.secondary) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-sm text-gray-400">
|
||
<span class="text-xs">N/A</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-sm text-gray-400">
|
||
<span class="text-xs">N/A</span>
|
||
</div>
|
||
</td>
|
||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||
{{ formatLastUsed(account.lastUsedAt) }}
|
||
</td>
|
||
<td class="whitespace-nowrap px-3 py-4 text-sm font-medium">
|
||
<div class="flex flex-wrap items-center gap-1">
|
||
<button
|
||
v-if="
|
||
(account.platform === 'claude' ||
|
||
account.platform === 'claude-console' ||
|
||
account.platform === 'openai' ||
|
||
account.platform === 'openai-responses') &&
|
||
(account.status === 'unauthorized' ||
|
||
account.status !== 'active' ||
|
||
account.rateLimitStatus?.isRateLimited ||
|
||
account.rateLimitStatus === 'limited' ||
|
||
!account.isActive)
|
||
"
|
||
:class="[
|
||
'rounded px-2.5 py-1 text-xs font-medium transition-colors',
|
||
account.isResetting
|
||
? 'cursor-not-allowed bg-gray-100 text-gray-400'
|
||
: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
|
||
]"
|
||
:disabled="account.isResetting"
|
||
:title="account.isResetting ? '重置中...' : '重置所有异常状态'"
|
||
@click="resetAccountStatus(account)"
|
||
>
|
||
<i :class="['fas fa-redo', account.isResetting ? 'animate-spin' : '']" />
|
||
<span class="ml-1">重置状态</span>
|
||
</button>
|
||
<button
|
||
:class="[
|
||
'rounded px-2.5 py-1 text-xs font-medium transition-colors',
|
||
account.isTogglingSchedulable
|
||
? 'cursor-not-allowed bg-gray-100 text-gray-400'
|
||
: account.schedulable
|
||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||
]"
|
||
:disabled="account.isTogglingSchedulable"
|
||
:title="account.schedulable ? '点击禁用调度' : '点击启用调度'"
|
||
@click="toggleSchedulable(account)"
|
||
>
|
||
<i :class="['fas', account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off']" />
|
||
<span class="ml-1">{{ account.schedulable ? '调度' : '停用' }}</span>
|
||
</button>
|
||
<button
|
||
v-if="canViewUsage(account)"
|
||
class="rounded bg-indigo-100 px-2.5 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-200"
|
||
:title="'查看使用详情'"
|
||
@click="openAccountUsageModal(account)"
|
||
>
|
||
<i class="fas fa-chart-line" />
|
||
<span class="ml-1">详情</span>
|
||
</button>
|
||
<button
|
||
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
||
:title="'编辑账户'"
|
||
@click="editAccount(account)"
|
||
>
|
||
<i class="fas fa-edit" />
|
||
<span class="ml-1">编辑</span>
|
||
</button>
|
||
<button
|
||
class="rounded bg-red-100 px-2.5 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-200"
|
||
:title="'删除账户'"
|
||
@click="deleteAccount(account)"
|
||
>
|
||
<i class="fas fa-trash" />
|
||
<span class="ml-1">删除</span>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 移动端卡片视图 -->
|
||
<div v-if="!accountsLoading && sortedAccounts.length > 0" class="space-y-3 md:hidden">
|
||
<div
|
||
v-for="account in paginatedAccounts"
|
||
:key="account.id"
|
||
class="card p-4 transition-shadow hover:shadow-lg"
|
||
>
|
||
<!-- 卡片头部 -->
|
||
<div class="mb-3 flex items-start justify-between">
|
||
<div class="flex items-center gap-3">
|
||
<input
|
||
v-if="shouldShowCheckboxes"
|
||
v-model="selectedAccounts"
|
||
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
type="checkbox"
|
||
:value="account.id"
|
||
@change="updateSelectAllState"
|
||
/>
|
||
<div
|
||
:class="[
|
||
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg',
|
||
account.platform === 'claude'
|
||
? 'bg-gradient-to-br from-purple-500 to-purple-600'
|
||
: account.platform === 'bedrock'
|
||
? 'bg-gradient-to-br from-orange-500 to-red-600'
|
||
: account.platform === 'azure_openai'
|
||
? 'bg-gradient-to-br from-blue-500 to-cyan-600'
|
||
: account.platform === 'openai'
|
||
? 'bg-gradient-to-br from-gray-600 to-gray-700'
|
||
: account.platform === 'ccr'
|
||
? 'bg-gradient-to-br from-teal-500 to-emerald-600'
|
||
: account.platform === 'droid'
|
||
? 'bg-gradient-to-br from-cyan-500 to-sky-600'
|
||
: 'bg-gradient-to-br from-blue-500 to-blue-600'
|
||
]"
|
||
>
|
||
<i
|
||
:class="[
|
||
'text-sm text-white',
|
||
account.platform === 'claude'
|
||
? 'fas fa-brain'
|
||
: account.platform === 'bedrock'
|
||
? 'fab fa-aws'
|
||
: account.platform === 'azure_openai'
|
||
? 'fab fa-microsoft'
|
||
: account.platform === 'openai'
|
||
? 'fas fa-openai'
|
||
: account.platform === 'ccr'
|
||
? 'fas fa-code-branch'
|
||
: account.platform === 'droid'
|
||
? 'fas fa-robot'
|
||
: 'fas fa-robot'
|
||
]"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<h4 class="text-sm font-semibold text-gray-900">
|
||
{{ account.name || account.email }}
|
||
</h4>
|
||
<div class="mt-0.5 flex items-center gap-2">
|
||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||
account.platform
|
||
}}</span>
|
||
<span class="text-xs text-gray-400">|</span>
|
||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ account.type }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span
|
||
:class="[
|
||
'inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold',
|
||
getAccountStatusClass(account)
|
||
]"
|
||
>
|
||
<div
|
||
:class="['mr-1.5 h-1.5 w-1.5 rounded-full', getAccountStatusDotClass(account)]"
|
||
/>
|
||
{{ getAccountStatusText(account) }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 使用统计 -->
|
||
<div class="mb-3 grid grid-cols-2 gap-3">
|
||
<div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p>
|
||
<div class="space-y-1">
|
||
<div class="flex items-center gap-1.5">
|
||
<div class="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||
{{ account.usage?.daily?.requests || 0 }} 次
|
||
</p>
|
||
</div>
|
||
<div class="flex items-center gap-1.5">
|
||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}M
|
||
</p>
|
||
</div>
|
||
<div class="flex items-center gap-1.5">
|
||
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||
${{ calculateDailyCost(account) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">会话窗口</p>
|
||
<div v-if="account.usage && account.usage.sessionWindow" class="space-y-1">
|
||
<div class="flex items-center gap-1.5">
|
||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
|
||
</p>
|
||
</div>
|
||
<div class="flex items-center gap-1.5">
|
||
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||
${{ formatCost(account.usage.sessionWindow.totalCost) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-sm font-semibold text-gray-400">-</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 状态信息 -->
|
||
<div class="mb-3 space-y-2">
|
||
<!-- 会话窗口 -->
|
||
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||
<div v-if="isClaudeOAuth(account) && account.claudeUsage" class="space-y-2">
|
||
<!-- 5小时窗口 -->
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300"
|
||
>
|
||
5h
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getClaudeUsageBarClass(account.claudeUsage.fiveHour)
|
||
]"
|
||
:style="{
|
||
width: getClaudeUsageWidth(account.claudeUsage.fiveHour)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatClaudeUsagePercent(account.claudeUsage.fiveHour) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.fiveHour) }}
|
||
</div>
|
||
</div>
|
||
<!-- 7天窗口 -->
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300"
|
||
>
|
||
7d
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getClaudeUsageBarClass(account.claudeUsage.sevenDay)
|
||
]"
|
||
:style="{
|
||
width: getClaudeUsageWidth(account.claudeUsage.sevenDay)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDay) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDay) }}
|
||
</div>
|
||
</div>
|
||
<!-- 7天Opus窗口 -->
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-purple-100 px-2 py-0.5 text-[11px] font-medium text-purple-600 dark:bg-purple-500/20 dark:text-purple-300"
|
||
>
|
||
Opus
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getClaudeUsageBarClass(account.claudeUsage.sevenDayOpus)
|
||
]"
|
||
:style="{
|
||
width: getClaudeUsageWidth(account.claudeUsage.sevenDayOpus)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDayOpus) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDayOpus) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Setup Token 账户:显示原有的会话窗口时间进度 -->
|
||
<div
|
||
v-else-if="
|
||
!isClaudeOAuth(account) &&
|
||
account.sessionWindow &&
|
||
account.sessionWindow.hasActiveWindow
|
||
"
|
||
class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700"
|
||
>
|
||
<div class="flex items-center justify-between text-xs">
|
||
<div class="flex items-center gap-1">
|
||
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
|
||
<el-tooltip
|
||
content="会话窗口进度不代表使用量,仅表示距离下一个5小时窗口的剩余时间"
|
||
placement="top"
|
||
>
|
||
<i
|
||
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600"
|
||
/>
|
||
</el-tooltip>
|
||
</div>
|
||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||
{{ account.sessionWindow.progress }}%
|
||
</span>
|
||
</div>
|
||
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-full transition-all duration-300',
|
||
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus, account)
|
||
]"
|
||
:style="{ width: account.sessionWindow.progress + '%' }"
|
||
/>
|
||
</div>
|
||
<div class="flex items-center justify-between text-xs">
|
||
<span class="text-gray-500 dark:text-gray-400">
|
||
{{
|
||
formatSessionWindow(
|
||
account.sessionWindow.windowStart,
|
||
account.sessionWindow.windowEnd
|
||
)
|
||
}}
|
||
</span>
|
||
<span
|
||
v-if="account.sessionWindow.remainingTime > 0"
|
||
class="font-medium text-indigo-600"
|
||
>
|
||
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
|
||
</span>
|
||
<span v-else class="text-gray-500"> 已结束 </span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="text-xs text-gray-400">暂无统计</div>
|
||
</div>
|
||
<div v-else-if="account.platform === 'openai'" class="space-y-2">
|
||
<div v-if="account.codexUsage" class="space-y-2">
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300"
|
||
>
|
||
{{ getCodexWindowLabel('primary') }}
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getCodexUsageBarClass(account.codexUsage.primary)
|
||
]"
|
||
:style="{
|
||
width: getCodexUsageWidth(account.codexUsage.primary)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatCodexUsagePercent(account.codexUsage.primary) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }}
|
||
</div>
|
||
</div>
|
||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="inline-flex min-w-[32px] justify-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-600 dark:bg-blue-500/20 dark:text-blue-300"
|
||
>
|
||
{{ getCodexWindowLabel('secondary') }}
|
||
</span>
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||
<div
|
||
:class="[
|
||
'h-2 rounded-full transition-all duration-300',
|
||
getCodexUsageBarClass(account.codexUsage.secondary)
|
||
]"
|
||
:style="{
|
||
width: getCodexUsageWidth(account.codexUsage.secondary)
|
||
}"
|
||
/>
|
||
</div>
|
||
<span
|
||
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||
>
|
||
{{ formatCodexUsagePercent(account.codexUsage.secondary) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="!account.codexUsage" class="text-xs text-gray-400">暂无统计</div>
|
||
</div>
|
||
|
||
<!-- 最后使用时间 -->
|
||
<div class="flex items-center justify-between text-xs">
|
||
<span class="text-gray-500 dark:text-gray-400">最后使用</span>
|
||
<span class="text-gray-700 dark:text-gray-200">
|
||
{{ account.lastUsedAt ? formatRelativeTime(account.lastUsedAt) : '从未使用' }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 代理配置 -->
|
||
<div
|
||
v-if="account.proxyConfig && account.proxyConfig.type !== 'none'"
|
||
class="flex items-center justify-between text-xs"
|
||
>
|
||
<span class="text-gray-500 dark:text-gray-400">代理</span>
|
||
<span class="text-gray-700 dark:text-gray-200">
|
||
{{ account.proxyConfig.type.toUpperCase() }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 调度优先级 -->
|
||
<div class="flex items-center justify-between text-xs">
|
||
<span class="text-gray-500 dark:text-gray-400">优先级</span>
|
||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||
{{ account.priority || 50 }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="mt-3 flex gap-2 border-t border-gray-100 pt-3">
|
||
<button
|
||
class="flex flex-1 items-center justify-center gap-1 rounded-lg px-3 py-2 text-xs transition-colors"
|
||
:class="
|
||
account.schedulable
|
||
? 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||
: 'bg-green-50 text-green-600 hover:bg-green-100'
|
||
"
|
||
:disabled="account.isTogglingSchedulable"
|
||
@click="toggleSchedulable(account)"
|
||
>
|
||
<i :class="['fas', account.schedulable ? 'fa-pause' : 'fa-play']" />
|
||
{{ account.schedulable ? '暂停' : '启用' }}
|
||
</button>
|
||
|
||
<button
|
||
v-if="canViewUsage(account)"
|
||
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-indigo-50 px-3 py-2 text-xs text-indigo-600 transition-colors hover:bg-indigo-100"
|
||
@click="openAccountUsageModal(account)"
|
||
>
|
||
<i class="fas fa-chart-line" />
|
||
详情
|
||
</button>
|
||
|
||
<button
|
||
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
||
@click="editAccount(account)"
|
||
>
|
||
<i class="fas fa-edit mr-1" />
|
||
编辑
|
||
</button>
|
||
|
||
<button
|
||
class="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600 transition-colors hover:bg-red-100"
|
||
@click="deleteAccount(account)"
|
||
>
|
||
<i class="fas fa-trash" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="!accountsLoading && sortedAccounts.length > 0"
|
||
class="mt-4 flex flex-col items-center justify-between gap-4 sm:mt-6 sm:flex-row"
|
||
>
|
||
<div class="flex w-full flex-col items-center gap-3 sm:w-auto sm:flex-row">
|
||
<span class="text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
|
||
共 {{ sortedAccounts.length }} 条记录
|
||
</span>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xs text-gray-600 dark:text-gray-400 sm:text-sm">每页显示</span>
|
||
<select
|
||
v-model="pageSize"
|
||
class="rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 transition-colors hover:border-gray-300 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:text-sm"
|
||
@change="currentPage = 1"
|
||
>
|
||
<option v-for="size in pageSizeOptions" :key="size" :value="size">
|
||
{{ size }}
|
||
</option>
|
||
</select>
|
||
<span class="text-xs text-gray-600 dark:text-gray-400 sm:text-sm">条</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 sm:py-1 sm:text-sm"
|
||
:disabled="currentPage === 1"
|
||
@click="currentPage--"
|
||
>
|
||
<i class="fas fa-chevron-left" />
|
||
</button>
|
||
|
||
<div class="flex items-center gap-1">
|
||
<button
|
||
v-if="shouldShowFirstPage"
|
||
class="hidden rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 sm:block"
|
||
@click="currentPage = 1"
|
||
>
|
||
1
|
||
</button>
|
||
|
||
<span
|
||
v-if="showLeadingEllipsis"
|
||
class="hidden px-2 text-sm text-gray-500 dark:text-gray-400 sm:block"
|
||
>
|
||
...
|
||
</span>
|
||
|
||
<button
|
||
v-for="page in pageNumbers"
|
||
:key="page"
|
||
:class="[
|
||
'rounded-md border px-3 py-1 text-xs font-medium transition-colors sm:text-sm',
|
||
page === currentPage
|
||
? 'border-blue-500 bg-blue-50 text-blue-600 dark:border-blue-400 dark:bg-blue-500/10 dark:text-blue-300'
|
||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||
]"
|
||
@click="currentPage = page"
|
||
>
|
||
{{ page }}
|
||
</button>
|
||
|
||
<span
|
||
v-if="showTrailingEllipsis"
|
||
class="hidden px-2 text-sm text-gray-500 dark:text-gray-400 sm:block"
|
||
>
|
||
...
|
||
</span>
|
||
|
||
<button
|
||
v-if="shouldShowLastPage"
|
||
class="hidden rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 sm:block"
|
||
@click="currentPage = totalPages"
|
||
>
|
||
{{ totalPages }}
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 sm:py-1 sm:text-sm"
|
||
:disabled="currentPage === totalPages || totalPages === 0"
|
||
@click="currentPage++"
|
||
>
|
||
<i class="fas fa-chevron-right" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 添加账户模态框 -->
|
||
<AccountForm
|
||
v-if="showCreateAccountModal && (!newAccountPlatform || newAccountPlatform !== 'ccr')"
|
||
@close="closeCreateAccountModal"
|
||
@platform-changed="newAccountPlatform = $event"
|
||
@success="handleCreateSuccess"
|
||
/>
|
||
<CcrAccountForm
|
||
v-else-if="showCreateAccountModal && newAccountPlatform === 'ccr'"
|
||
@close="closeCreateAccountModal"
|
||
@success="handleCreateSuccess"
|
||
/>
|
||
|
||
<!-- 编辑账户模态框 -->
|
||
<CcrAccountForm
|
||
v-if="showEditAccountModal && editingAccount && editingAccount.platform === 'ccr'"
|
||
:account="editingAccount"
|
||
@close="showEditAccountModal = false"
|
||
@success="handleEditSuccess"
|
||
/>
|
||
<AccountForm
|
||
v-else-if="showEditAccountModal"
|
||
:account="editingAccount"
|
||
@close="showEditAccountModal = false"
|
||
@success="handleEditSuccess"
|
||
/>
|
||
|
||
<!-- 确认弹窗 -->
|
||
<ConfirmModal
|
||
:cancel-text="confirmOptions.cancelText"
|
||
:confirm-text="confirmOptions.confirmText"
|
||
:message="confirmOptions.message"
|
||
:show="showConfirmModal"
|
||
:title="confirmOptions.title"
|
||
@cancel="handleCancel"
|
||
@confirm="handleConfirm"
|
||
/>
|
||
|
||
<AccountUsageDetailModal
|
||
v-if="showAccountUsageModal"
|
||
:account="selectedAccountForUsage || {}"
|
||
:generated-at="accountUsageGeneratedAt"
|
||
:history="accountUsageHistory"
|
||
:loading="accountUsageLoading"
|
||
:overview="accountUsageOverview"
|
||
:show="showAccountUsageModal"
|
||
:summary="accountUsageSummary"
|
||
@close="closeAccountUsageModal"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, watch } from 'vue'
|
||
import { showToast } from '@/utils/toast'
|
||
import { apiClient } from '@/config/api'
|
||
import { useConfirm } from '@/composables/useConfirm'
|
||
import AccountForm from '@/components/accounts/AccountForm.vue'
|
||
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||
|
||
// 使用确认弹窗
|
||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||
|
||
// 数据状态
|
||
const accounts = ref([])
|
||
const accountsLoading = ref(false)
|
||
const accountSortBy = ref('name')
|
||
const accountsSortBy = ref('')
|
||
const accountsSortOrder = ref('asc')
|
||
const apiKeys = ref([])
|
||
const accountGroups = ref([])
|
||
const groupFilter = ref('all')
|
||
const platformFilter = ref('all')
|
||
const searchKeyword = ref('')
|
||
const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize'
|
||
const getInitialPageSize = () => {
|
||
const saved = localStorage.getItem(PAGE_SIZE_STORAGE_KEY)
|
||
if (saved) {
|
||
const parsedSize = parseInt(saved, 10)
|
||
if ([10, 20, 50, 100].includes(parsedSize)) {
|
||
return parsedSize
|
||
}
|
||
}
|
||
return 10
|
||
}
|
||
const pageSizeOptions = [10, 20, 50, 100]
|
||
const pageSize = ref(getInitialPageSize())
|
||
const currentPage = ref(1)
|
||
|
||
// 多选状态
|
||
const selectedAccounts = ref([])
|
||
const selectAllChecked = ref(false)
|
||
const isIndeterminate = ref(false)
|
||
const showCheckboxes = ref(false)
|
||
|
||
// 账号使用详情弹窗状态
|
||
const showAccountUsageModal = ref(false)
|
||
const accountUsageLoading = ref(false)
|
||
const selectedAccountForUsage = ref(null)
|
||
const accountUsageHistory = ref([])
|
||
const accountUsageSummary = ref({})
|
||
const accountUsageOverview = ref({})
|
||
const accountUsageGeneratedAt = ref('')
|
||
|
||
const supportedUsagePlatforms = ['claude', 'claude-console', 'openai', 'openai-responses', 'gemini']
|
||
|
||
// 缓存状态标志
|
||
const apiKeysLoaded = ref(false)
|
||
const groupsLoaded = ref(false)
|
||
const groupMembersLoaded = ref(false)
|
||
const accountGroupMap = ref(new Map()) // Map<accountId, Array<groupInfo>>
|
||
|
||
// 下拉选项数据
|
||
const sortOptions = ref([
|
||
{ value: 'name', label: '按名称排序', icon: 'fa-font' },
|
||
{ value: 'dailyTokens', label: '按今日Token排序', icon: 'fa-coins' },
|
||
{ value: 'dailyRequests', label: '按今日请求数排序', icon: 'fa-chart-line' },
|
||
{ value: 'totalTokens', label: '按总Token排序', icon: 'fa-database' },
|
||
{ value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' }
|
||
])
|
||
|
||
const platformOptions = ref([
|
||
{ value: 'all', label: '所有平台', icon: 'fa-globe' },
|
||
{ value: 'claude', label: 'Claude', icon: 'fa-brain' },
|
||
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
|
||
{ value: 'gemini', label: 'Gemini', icon: 'fab fa-google' },
|
||
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
||
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
|
||
{ value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' },
|
||
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' },
|
||
{ value: 'droid', label: 'Droid', icon: 'fa-robot' }
|
||
])
|
||
|
||
const groupOptions = computed(() => {
|
||
const options = [
|
||
{ value: 'all', label: '所有账户', icon: 'fa-globe' },
|
||
{ value: 'ungrouped', label: '未分组账户', icon: 'fa-user' }
|
||
]
|
||
accountGroups.value.forEach((group) => {
|
||
options.push({
|
||
value: group.id,
|
||
label: `${group.name} (${group.platform === 'claude' ? 'Claude' : group.platform === 'gemini' ? 'Gemini' : 'OpenAI'})`,
|
||
icon:
|
||
group.platform === 'claude'
|
||
? 'fa-brain'
|
||
: group.platform === 'gemini'
|
||
? 'fa-robot'
|
||
: 'fa-openai'
|
||
})
|
||
})
|
||
return options
|
||
})
|
||
|
||
const shouldShowCheckboxes = computed(() => showCheckboxes.value)
|
||
|
||
// 模态框状态
|
||
const showCreateAccountModal = ref(false)
|
||
const newAccountPlatform = ref(null) // 跟踪新建账户选择的平台
|
||
const showEditAccountModal = ref(false)
|
||
const editingAccount = ref(null)
|
||
|
||
const collectAccountSearchableStrings = (account) => {
|
||
const values = new Set()
|
||
|
||
const baseFields = [
|
||
account?.name,
|
||
account?.email,
|
||
account?.accountName,
|
||
account?.owner,
|
||
account?.ownerName,
|
||
account?.ownerDisplayName,
|
||
account?.displayName,
|
||
account?.username,
|
||
account?.identifier,
|
||
account?.alias,
|
||
account?.title,
|
||
account?.label
|
||
]
|
||
|
||
baseFields.forEach((field) => {
|
||
if (typeof field === 'string') {
|
||
const trimmed = field.trim()
|
||
if (trimmed) {
|
||
values.add(trimmed)
|
||
}
|
||
}
|
||
})
|
||
|
||
if (Array.isArray(account?.groupInfos)) {
|
||
account.groupInfos.forEach((group) => {
|
||
if (group && typeof group.name === 'string') {
|
||
const trimmed = group.name.trim()
|
||
if (trimmed) {
|
||
values.add(trimmed)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
Object.entries(account || {}).forEach(([key, value]) => {
|
||
if (typeof value === 'string') {
|
||
const lowerKey = key.toLowerCase()
|
||
if (lowerKey.includes('name') || lowerKey.includes('email')) {
|
||
const trimmed = value.trim()
|
||
if (trimmed) {
|
||
values.add(trimmed)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
return Array.from(values)
|
||
}
|
||
|
||
const accountMatchesKeyword = (account, normalizedKeyword) => {
|
||
if (!normalizedKeyword) return true
|
||
return collectAccountSearchableStrings(account).some((value) =>
|
||
value.toLowerCase().includes(normalizedKeyword)
|
||
)
|
||
}
|
||
|
||
const canViewUsage = (account) => !!account && supportedUsagePlatforms.includes(account.platform)
|
||
|
||
const openAccountUsageModal = async (account) => {
|
||
if (!canViewUsage(account)) {
|
||
showToast('该账户类型暂不支持查看详情', 'warning')
|
||
return
|
||
}
|
||
|
||
selectedAccountForUsage.value = account
|
||
showAccountUsageModal.value = true
|
||
accountUsageLoading.value = true
|
||
accountUsageHistory.value = []
|
||
accountUsageSummary.value = {}
|
||
accountUsageOverview.value = {}
|
||
accountUsageGeneratedAt.value = ''
|
||
|
||
try {
|
||
const response = await apiClient.get(
|
||
`/admin/accounts/${account.id}/usage-history?platform=${account.platform}&days=30`
|
||
)
|
||
|
||
if (response.success) {
|
||
const data = response.data || {}
|
||
accountUsageHistory.value = data.history || []
|
||
accountUsageSummary.value = data.summary || {}
|
||
accountUsageOverview.value = data.overview || {}
|
||
accountUsageGeneratedAt.value = data.generatedAt || ''
|
||
} else {
|
||
showToast(response.error || '加载账号使用详情失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
showToast('加载账号使用详情失败', 'error')
|
||
} finally {
|
||
accountUsageLoading.value = false
|
||
}
|
||
}
|
||
|
||
const closeAccountUsageModal = () => {
|
||
showAccountUsageModal.value = false
|
||
accountUsageLoading.value = false
|
||
selectedAccountForUsage.value = null
|
||
}
|
||
|
||
// 计算排序后的账户列表
|
||
const sortedAccounts = computed(() => {
|
||
let sourceAccounts = accounts.value
|
||
|
||
const keyword = searchKeyword.value.trim()
|
||
if (keyword) {
|
||
const normalizedKeyword = keyword.toLowerCase()
|
||
sourceAccounts = sourceAccounts.filter((account) =>
|
||
accountMatchesKeyword(account, normalizedKeyword)
|
||
)
|
||
}
|
||
|
||
if (!accountsSortBy.value) return sourceAccounts
|
||
|
||
const sorted = [...sourceAccounts].sort((a, b) => {
|
||
let aVal = a[accountsSortBy.value]
|
||
let bVal = b[accountsSortBy.value]
|
||
|
||
// 处理统计数据
|
||
if (accountsSortBy.value === 'dailyTokens') {
|
||
aVal = a.usage?.daily?.allTokens || 0
|
||
bVal = b.usage?.daily?.allTokens || 0
|
||
} else if (accountsSortBy.value === 'dailyRequests') {
|
||
aVal = a.usage?.daily?.requests || 0
|
||
bVal = b.usage?.daily?.requests || 0
|
||
} else if (accountsSortBy.value === 'totalTokens') {
|
||
aVal = a.usage?.total?.allTokens || 0
|
||
bVal = b.usage?.total?.allTokens || 0
|
||
}
|
||
|
||
// 处理最后使用时间
|
||
if (accountsSortBy.value === 'lastUsed') {
|
||
aVal = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
||
bVal = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
||
}
|
||
|
||
// 处理状态
|
||
if (accountsSortBy.value === 'status') {
|
||
aVal = a.isActive ? 1 : 0
|
||
bVal = b.isActive ? 1 : 0
|
||
}
|
||
|
||
if (aVal < bVal) return accountsSortOrder.value === 'asc' ? -1 : 1
|
||
if (aVal > bVal) return accountsSortOrder.value === 'asc' ? 1 : -1
|
||
return 0
|
||
})
|
||
|
||
return sorted
|
||
})
|
||
|
||
const totalPages = computed(() => {
|
||
const total = sortedAccounts.value.length
|
||
return Math.ceil(total / pageSize.value) || 0
|
||
})
|
||
|
||
const pageNumbers = computed(() => {
|
||
const total = totalPages.value
|
||
const current = currentPage.value
|
||
const pages = []
|
||
|
||
if (total <= 7) {
|
||
for (let i = 1; i <= total; i++) {
|
||
pages.push(i)
|
||
}
|
||
} else {
|
||
let start = Math.max(1, current - 2)
|
||
let end = Math.min(total, current + 2)
|
||
|
||
if (current <= 3) {
|
||
end = 5
|
||
} else if (current >= total - 2) {
|
||
start = total - 4
|
||
}
|
||
|
||
for (let i = start; i <= end; i++) {
|
||
pages.push(i)
|
||
}
|
||
}
|
||
|
||
return pages
|
||
})
|
||
|
||
const shouldShowFirstPage = computed(() => {
|
||
const pages = pageNumbers.value
|
||
if (pages.length === 0) return false
|
||
return pages[0] > 1
|
||
})
|
||
|
||
const shouldShowLastPage = computed(() => {
|
||
const pages = pageNumbers.value
|
||
if (pages.length === 0) return false
|
||
return pages[pages.length - 1] < totalPages.value
|
||
})
|
||
|
||
const showLeadingEllipsis = computed(() => {
|
||
const pages = pageNumbers.value
|
||
if (pages.length === 0) return false
|
||
return shouldShowFirstPage.value && pages[0] > 2
|
||
})
|
||
|
||
const showTrailingEllipsis = computed(() => {
|
||
const pages = pageNumbers.value
|
||
if (pages.length === 0) return false
|
||
return shouldShowLastPage.value && pages[pages.length - 1] < totalPages.value - 1
|
||
})
|
||
|
||
const paginatedAccounts = computed(() => {
|
||
const start = (currentPage.value - 1) * pageSize.value
|
||
const end = start + pageSize.value
|
||
return sortedAccounts.value.slice(start, end)
|
||
})
|
||
|
||
const updateSelectAllState = () => {
|
||
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
||
const selectedInCurrentPage = currentIds.filter((id) =>
|
||
selectedAccounts.value.includes(id)
|
||
).length
|
||
const totalInCurrentPage = currentIds.length
|
||
|
||
if (selectedInCurrentPage === 0) {
|
||
selectAllChecked.value = false
|
||
isIndeterminate.value = false
|
||
} else if (selectedInCurrentPage === totalInCurrentPage) {
|
||
selectAllChecked.value = true
|
||
isIndeterminate.value = false
|
||
} else {
|
||
selectAllChecked.value = false
|
||
isIndeterminate.value = true
|
||
}
|
||
}
|
||
|
||
const handleSelectAll = () => {
|
||
if (selectAllChecked.value) {
|
||
paginatedAccounts.value.forEach((account) => {
|
||
if (!selectedAccounts.value.includes(account.id)) {
|
||
selectedAccounts.value.push(account.id)
|
||
}
|
||
})
|
||
} else {
|
||
const currentIds = new Set(paginatedAccounts.value.map((account) => account.id))
|
||
selectedAccounts.value = selectedAccounts.value.filter((id) => !currentIds.has(id))
|
||
}
|
||
updateSelectAllState()
|
||
}
|
||
|
||
const toggleSelectionMode = () => {
|
||
showCheckboxes.value = !showCheckboxes.value
|
||
if (!showCheckboxes.value) {
|
||
selectedAccounts.value = []
|
||
selectAllChecked.value = false
|
||
isIndeterminate.value = false
|
||
} else {
|
||
updateSelectAllState()
|
||
}
|
||
}
|
||
|
||
const cleanupSelectedAccounts = () => {
|
||
const validIds = new Set(accounts.value.map((account) => account.id))
|
||
selectedAccounts.value = selectedAccounts.value.filter((id) => validIds.has(id))
|
||
updateSelectAllState()
|
||
}
|
||
|
||
// 加载账户列表
|
||
const loadAccounts = async (forceReload = false) => {
|
||
accountsLoading.value = true
|
||
try {
|
||
// 构建查询参数(用于其他筛选情况)
|
||
const params = {}
|
||
if (platformFilter.value !== 'all') {
|
||
params.platform = platformFilter.value
|
||
}
|
||
if (groupFilter.value !== 'all') {
|
||
params.groupId = groupFilter.value
|
||
}
|
||
|
||
// 根据平台筛选决定需要请求哪些接口
|
||
const requests = []
|
||
|
||
if (platformFilter.value === 'all') {
|
||
// 请求所有平台
|
||
requests.push(
|
||
apiClient.get('/admin/claude-accounts', { params }),
|
||
apiClient.get('/admin/claude-console-accounts', { params }),
|
||
apiClient.get('/admin/bedrock-accounts', { params }),
|
||
apiClient.get('/admin/gemini-accounts', { params }),
|
||
apiClient.get('/admin/openai-accounts', { params }),
|
||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
||
apiClient.get('/admin/openai-responses-accounts', { params }),
|
||
apiClient.get('/admin/ccr-accounts', { params }),
|
||
apiClient.get('/admin/droid-accounts', { params })
|
||
)
|
||
} else {
|
||
// 只请求指定平台,其他平台设为null占位
|
||
switch (platformFilter.value) {
|
||
case 'claude':
|
||
requests.push(
|
||
apiClient.get('/admin/claude-accounts', { params }),
|
||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||
)
|
||
break
|
||
case 'claude-console':
|
||
requests.push(
|
||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||
apiClient.get('/admin/claude-console-accounts', { params }),
|
||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||
)
|
||
break
|
||
case 'bedrock':
|
||
requests.push(
|
||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||
apiClient.get('/admin/bedrock-accounts', { params }),
|
||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||
)
|
||
break
|
||
case 'gemini':
|
||
requests.push(
|
||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||
apiClient.get('/admin/gemini-accounts', { params }),
|
||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||
)
|
||
break
|
||
case 'openai':
|
||
requests.push(
|
||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||
apiClient.get('/admin/openai-accounts', { params }),
|
||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||
)
|
||
break
|
||
case 'azure_openai':
|
||
requests.push(
|
||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||
)
|
||
break
|
||
case 'openai-responses':
|
||
requests.push(
|
||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||
apiClient.get('/admin/openai-responses-accounts', { params }),
|
||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||
)
|
||
break
|
||
case 'ccr':
|
||
requests.push(
|
||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // azure 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||
apiClient.get('/admin/ccr-accounts', { params }),
|
||
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||
)
|
||
break
|
||
case 'droid':
|
||
requests.push(
|
||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||
Promise.resolve({ success: true, data: [] }), // azure 占位
|
||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||
apiClient.get('/admin/droid-accounts', { params })
|
||
)
|
||
break
|
||
default:
|
||
// 默认情况下返回空数组
|
||
requests.push(
|
||
Promise.resolve({ success: true, data: [] }),
|
||
Promise.resolve({ success: true, data: [] }),
|
||
Promise.resolve({ success: true, data: [] }),
|
||
Promise.resolve({ success: true, data: [] }),
|
||
Promise.resolve({ success: true, data: [] }),
|
||
Promise.resolve({ success: true, data: [] }),
|
||
Promise.resolve({ success: true, data: [] }),
|
||
Promise.resolve({ success: true, data: [] }),
|
||
Promise.resolve({ success: true, data: [] })
|
||
)
|
||
break
|
||
}
|
||
}
|
||
|
||
// 使用缓存机制加载 API Keys 和分组数据
|
||
await Promise.all([loadApiKeys(forceReload), loadAccountGroups(forceReload)])
|
||
|
||
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
||
// await loadGroupMembers(forceReload)
|
||
|
||
const [
|
||
claudeData,
|
||
claudeConsoleData,
|
||
bedrockData,
|
||
geminiData,
|
||
openaiData,
|
||
azureOpenaiData,
|
||
openaiResponsesData,
|
||
ccrData,
|
||
droidData
|
||
] = await Promise.all(requests)
|
||
|
||
const allAccounts = []
|
||
|
||
if (claudeData.success) {
|
||
const claudeAccounts = (claudeData.data || []).map((acc) => {
|
||
// 计算每个Claude账户绑定的API Key数量
|
||
const boundApiKeysCount = apiKeys.value.filter(
|
||
(key) => key.claudeAccountId === acc.id
|
||
).length
|
||
// 后端已经包含了groupInfos,直接使用
|
||
return { ...acc, platform: 'claude', boundApiKeysCount }
|
||
})
|
||
allAccounts.push(...claudeAccounts)
|
||
}
|
||
|
||
if (claudeConsoleData.success) {
|
||
const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => {
|
||
// 计算每个Claude Console账户绑定的API Key数量
|
||
const boundApiKeysCount = apiKeys.value.filter(
|
||
(key) => key.claudeConsoleAccountId === acc.id
|
||
).length
|
||
// 后端已经包含了groupInfos,直接使用
|
||
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
||
})
|
||
allAccounts.push(...claudeConsoleAccounts)
|
||
}
|
||
|
||
if (bedrockData.success) {
|
||
const bedrockAccounts = (bedrockData.data || []).map((acc) => {
|
||
// Bedrock账户暂时不支持直接绑定
|
||
// 后端已经包含了groupInfos,直接使用
|
||
return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 }
|
||
})
|
||
allAccounts.push(...bedrockAccounts)
|
||
}
|
||
|
||
if (geminiData.success) {
|
||
const geminiAccounts = (geminiData.data || []).map((acc) => {
|
||
// 计算每个Gemini账户绑定的API Key数量
|
||
const boundApiKeysCount = apiKeys.value.filter(
|
||
(key) => key.geminiAccountId === acc.id
|
||
).length
|
||
// 后端已经包含了groupInfos,直接使用
|
||
return { ...acc, platform: 'gemini', boundApiKeysCount }
|
||
})
|
||
allAccounts.push(...geminiAccounts)
|
||
}
|
||
if (openaiData.success) {
|
||
const openaiAccounts = (openaiData.data || []).map((acc) => {
|
||
// 计算每个OpenAI账户绑定的API Key数量
|
||
const boundApiKeysCount = apiKeys.value.filter(
|
||
(key) => key.openaiAccountId === acc.id
|
||
).length
|
||
// 后端已经包含了groupInfos,直接使用
|
||
return { ...acc, platform: 'openai', boundApiKeysCount }
|
||
})
|
||
allAccounts.push(...openaiAccounts)
|
||
}
|
||
if (azureOpenaiData && azureOpenaiData.success) {
|
||
const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => {
|
||
// 计算每个Azure OpenAI账户绑定的API Key数量
|
||
const boundApiKeysCount = apiKeys.value.filter(
|
||
(key) => key.azureOpenaiAccountId === acc.id
|
||
).length
|
||
// 后端已经包含了groupInfos,直接使用
|
||
return { ...acc, platform: 'azure_openai', boundApiKeysCount }
|
||
})
|
||
allAccounts.push(...azureOpenaiAccounts)
|
||
}
|
||
|
||
if (openaiResponsesData && openaiResponsesData.success) {
|
||
const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => {
|
||
// 计算每个OpenAI-Responses账户绑定的API Key数量
|
||
// OpenAI-Responses账户使用 responses: 前缀
|
||
const boundApiKeysCount = apiKeys.value.filter(
|
||
(key) => key.openaiAccountId === `responses:${acc.id}`
|
||
).length
|
||
// 后端已经包含了groupInfos,直接使用
|
||
return { ...acc, platform: 'openai-responses', boundApiKeysCount }
|
||
})
|
||
allAccounts.push(...openaiResponsesAccounts)
|
||
}
|
||
|
||
// CCR 账户
|
||
if (ccrData && ccrData.success) {
|
||
const ccrAccounts = (ccrData.data || []).map((acc) => {
|
||
// CCR 不支持 API Key 绑定,固定为 0
|
||
return { ...acc, platform: 'ccr', boundApiKeysCount: 0 }
|
||
})
|
||
allAccounts.push(...ccrAccounts)
|
||
}
|
||
|
||
// Droid 账户
|
||
if (droidData && droidData.success) {
|
||
const droidAccounts = (droidData.data || []).map((acc) => {
|
||
// Droid 不支持 API Key 绑定,固定为 0
|
||
return { ...acc, platform: 'droid', boundApiKeysCount: 0 }
|
||
})
|
||
allAccounts.push(...droidAccounts)
|
||
}
|
||
|
||
// 根据分组筛选器过滤账户
|
||
let filteredAccounts = allAccounts
|
||
if (groupFilter.value !== 'all') {
|
||
if (groupFilter.value === 'ungrouped') {
|
||
// 筛选未分组的账户(没有 groupInfos 或 groupInfos 为空数组)
|
||
filteredAccounts = allAccounts.filter((account) => {
|
||
return !account.groupInfos || account.groupInfos.length === 0
|
||
})
|
||
} else {
|
||
// 筛选属于特定分组的账户
|
||
filteredAccounts = allAccounts.filter((account) => {
|
||
if (!account.groupInfos || account.groupInfos.length === 0) {
|
||
return false
|
||
}
|
||
// 检查账户是否属于选中的分组
|
||
return account.groupInfos.some((group) => group.id === groupFilter.value)
|
||
})
|
||
}
|
||
}
|
||
|
||
accounts.value = filteredAccounts
|
||
cleanupSelectedAccounts()
|
||
|
||
// 异步加载 Claude OAuth 账户的 usage 数据
|
||
if (filteredAccounts.some((acc) => acc.platform === 'claude')) {
|
||
loadClaudeUsage().catch((err) => {
|
||
console.debug('Claude usage loading failed:', err)
|
||
})
|
||
}
|
||
} catch (error) {
|
||
showToast('加载账户失败', 'error')
|
||
} finally {
|
||
accountsLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 异步加载 Claude 账户的 Usage 数据
|
||
const loadClaudeUsage = async () => {
|
||
try {
|
||
const response = await apiClient.get('/admin/claude-accounts/usage')
|
||
if (response.success && response.data) {
|
||
const usageMap = response.data
|
||
|
||
// 更新账户列表中的 claudeUsage 数据
|
||
accounts.value = accounts.value.map((account) => {
|
||
if (account.platform === 'claude' && usageMap[account.id]) {
|
||
return {
|
||
...account,
|
||
claudeUsage: usageMap[account.id]
|
||
}
|
||
}
|
||
return account
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.debug('Failed to load Claude usage data:', error)
|
||
}
|
||
}
|
||
|
||
// 排序账户
|
||
const sortAccounts = (field) => {
|
||
if (field) {
|
||
if (accountsSortBy.value === field) {
|
||
accountsSortOrder.value = accountsSortOrder.value === 'asc' ? 'desc' : 'asc'
|
||
} else {
|
||
accountsSortBy.value = field
|
||
accountsSortOrder.value = 'asc'
|
||
}
|
||
}
|
||
}
|
||
|
||
// 格式化数字(与原版保持一致)
|
||
const formatNumber = (num) => {
|
||
if (num === null || num === undefined) return '0'
|
||
const number = Number(num)
|
||
if (number >= 1000000) {
|
||
return (number / 1000000).toFixed(2)
|
||
} else if (number >= 1000) {
|
||
return (number / 1000000).toFixed(4)
|
||
}
|
||
return (number / 1000000).toFixed(6)
|
||
}
|
||
|
||
// 格式化最后使用时间
|
||
const formatLastUsed = (dateString) => {
|
||
if (!dateString) return '从未使用'
|
||
|
||
const date = new Date(dateString)
|
||
const now = new Date()
|
||
const diff = now - date
|
||
|
||
if (diff < 60000) return '刚刚'
|
||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
|
||
|
||
return date.toLocaleDateString('zh-CN')
|
||
}
|
||
|
||
const clearSearch = () => {
|
||
searchKeyword.value = ''
|
||
currentPage.value = 1
|
||
}
|
||
|
||
// 加载API Keys列表(缓存版本)
|
||
const loadApiKeys = async (forceReload = false) => {
|
||
if (!forceReload && apiKeysLoaded.value) {
|
||
return // 使用缓存数据
|
||
}
|
||
|
||
try {
|
||
const response = await apiClient.get('/admin/api-keys')
|
||
if (response.success) {
|
||
apiKeys.value = response.data || []
|
||
apiKeysLoaded.value = true
|
||
}
|
||
} catch (error) {
|
||
// 静默处理错误
|
||
}
|
||
}
|
||
|
||
// 加载账户分组列表(缓存版本)
|
||
const loadAccountGroups = async (forceReload = false) => {
|
||
if (!forceReload && groupsLoaded.value) {
|
||
return // 使用缓存数据
|
||
}
|
||
|
||
try {
|
||
const response = await apiClient.get('/admin/account-groups')
|
||
if (response.success) {
|
||
accountGroups.value = response.data || []
|
||
groupsLoaded.value = true
|
||
}
|
||
} catch (error) {
|
||
// 静默处理错误
|
||
}
|
||
}
|
||
|
||
// 清空缓存的函数
|
||
const clearCache = () => {
|
||
apiKeysLoaded.value = false
|
||
groupsLoaded.value = false
|
||
groupMembersLoaded.value = false
|
||
accountGroupMap.value.clear()
|
||
}
|
||
|
||
// 按平台筛选账户
|
||
const filterByPlatform = () => {
|
||
currentPage.value = 1
|
||
loadAccounts()
|
||
}
|
||
|
||
// 按分组筛选账户
|
||
const filterByGroup = () => {
|
||
currentPage.value = 1
|
||
loadAccounts()
|
||
}
|
||
|
||
// 格式化代理信息显示
|
||
const formatProxyDisplay = (proxy) => {
|
||
if (!proxy || !proxy.host || !proxy.port) return null
|
||
|
||
// 缩短类型名称
|
||
const typeShort = proxy.type === 'socks5' ? 'S5' : proxy.type.toUpperCase()
|
||
|
||
// 缩短主机名(如果太长)
|
||
let host = proxy.host
|
||
if (host.length > 15) {
|
||
host = host.substring(0, 12) + '...'
|
||
}
|
||
|
||
let display = `${typeShort}://${host}:${proxy.port}`
|
||
|
||
// 如果有用户名密码,添加认证信息(部分隐藏)
|
||
if (proxy.username) {
|
||
display = `${typeShort}://***@${host}:${proxy.port}`
|
||
}
|
||
|
||
return display
|
||
}
|
||
|
||
// 格式化会话窗口时间
|
||
const formatSessionWindow = (windowStart, windowEnd) => {
|
||
if (!windowStart || !windowEnd) return '--'
|
||
|
||
const start = new Date(windowStart)
|
||
const end = new Date(windowEnd)
|
||
|
||
const startHour = start.getHours().toString().padStart(2, '0')
|
||
const startMin = start.getMinutes().toString().padStart(2, '0')
|
||
const endHour = end.getHours().toString().padStart(2, '0')
|
||
const endMin = end.getMinutes().toString().padStart(2, '0')
|
||
|
||
return `${startHour}:${startMin} - ${endHour}:${endMin}`
|
||
}
|
||
|
||
// 格式化剩余时间
|
||
const formatRemainingTime = (minutes) => {
|
||
if (!minutes || minutes <= 0) return '已结束'
|
||
|
||
const hours = Math.floor(minutes / 60)
|
||
const mins = minutes % 60
|
||
|
||
if (hours > 0) {
|
||
return `${hours}小时${mins}分钟`
|
||
}
|
||
return `${mins}分钟`
|
||
}
|
||
|
||
// 格式化限流时间(支持显示天数)
|
||
const formatRateLimitTime = (minutes) => {
|
||
if (!minutes || minutes <= 0) return ''
|
||
|
||
// 转换为整数,避免小数
|
||
minutes = Math.floor(minutes)
|
||
|
||
// 计算天数、小时和分钟
|
||
const days = Math.floor(minutes / 1440) // 1天 = 1440分钟
|
||
const remainingAfterDays = minutes % 1440
|
||
const hours = Math.floor(remainingAfterDays / 60)
|
||
const mins = remainingAfterDays % 60
|
||
|
||
// 根据时间长度返回不同格式
|
||
if (days > 0) {
|
||
// 超过1天,显示天数和小时
|
||
if (hours > 0) {
|
||
return `${days}天${hours}小时`
|
||
}
|
||
return `${days}天`
|
||
} else if (hours > 0) {
|
||
// 超过1小时但不到1天,显示小时和分钟
|
||
if (mins > 0) {
|
||
return `${hours}小时${mins}分钟`
|
||
}
|
||
return `${hours}小时`
|
||
} else {
|
||
// 不到1小时,只显示分钟
|
||
return `${mins}分钟`
|
||
}
|
||
}
|
||
|
||
// 打开创建账户模态框
|
||
const openCreateAccountModal = () => {
|
||
newAccountPlatform.value = null // 重置选择的平台
|
||
showCreateAccountModal.value = true
|
||
}
|
||
|
||
// 关闭创建账户模态框
|
||
const closeCreateAccountModal = () => {
|
||
showCreateAccountModal.value = false
|
||
newAccountPlatform.value = null
|
||
}
|
||
|
||
// 编辑账户
|
||
const editAccount = (account) => {
|
||
editingAccount.value = account
|
||
showEditAccountModal.value = true
|
||
}
|
||
|
||
const getBoundApiKeysForAccount = (account) => {
|
||
if (!account || !account.id) return []
|
||
return apiKeys.value.filter((key) => {
|
||
const accountId = account.id
|
||
return (
|
||
key.claudeAccountId === accountId ||
|
||
key.claudeConsoleAccountId === accountId ||
|
||
key.geminiAccountId === accountId ||
|
||
key.openaiAccountId === accountId ||
|
||
key.azureOpenaiAccountId === accountId ||
|
||
key.openaiAccountId === `responses:${accountId}`
|
||
)
|
||
})
|
||
}
|
||
|
||
const resolveAccountDeleteEndpoint = (account) => {
|
||
switch (account.platform) {
|
||
case 'claude':
|
||
return `/admin/claude-accounts/${account.id}`
|
||
case 'claude-console':
|
||
return `/admin/claude-console-accounts/${account.id}`
|
||
case 'bedrock':
|
||
return `/admin/bedrock-accounts/${account.id}`
|
||
case 'openai':
|
||
return `/admin/openai-accounts/${account.id}`
|
||
case 'azure_openai':
|
||
return `/admin/azure-openai-accounts/${account.id}`
|
||
case 'openai-responses':
|
||
return `/admin/openai-responses-accounts/${account.id}`
|
||
case 'ccr':
|
||
return `/admin/ccr-accounts/${account.id}`
|
||
case 'gemini':
|
||
return `/admin/gemini-accounts/${account.id}`
|
||
case 'droid':
|
||
return `/admin/droid-accounts/${account.id}`
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
const performAccountDeletion = async (account) => {
|
||
const endpoint = resolveAccountDeleteEndpoint(account)
|
||
if (!endpoint) {
|
||
return { success: false, message: '不支持的账户类型' }
|
||
}
|
||
|
||
try {
|
||
const data = await apiClient.delete(endpoint)
|
||
if (data.success) {
|
||
return { success: true, data }
|
||
}
|
||
return { success: false, message: data.message || '删除失败' }
|
||
} catch (error) {
|
||
const message = error.response?.data?.message || error.message || '删除失败'
|
||
return { success: false, message }
|
||
}
|
||
}
|
||
|
||
// 删除账户
|
||
const deleteAccount = async (account) => {
|
||
const boundKeys = getBoundApiKeysForAccount(account)
|
||
const boundKeysCount = boundKeys.length
|
||
|
||
let confirmMessage = `确定要删除账户 "${account.name}" 吗?`
|
||
if (boundKeysCount > 0) {
|
||
confirmMessage += `\n\n⚠️ 注意:此账号有 ${boundKeysCount} 个 API Key 绑定。`
|
||
confirmMessage += `\n删除后,这些 API Key 将自动切换为共享池模式。`
|
||
}
|
||
confirmMessage += '\n\n此操作不可恢复。'
|
||
|
||
const confirmed = await showConfirm('删除账户', confirmMessage, '删除', '取消')
|
||
|
||
if (!confirmed) return
|
||
|
||
const result = await performAccountDeletion(account)
|
||
|
||
if (result.success) {
|
||
const data = result.data
|
||
let toastMessage = '账户已成功删除'
|
||
if (data?.unboundKeys > 0) {
|
||
toastMessage += `,${data.unboundKeys} 个 API Key 已切换为共享池模式`
|
||
}
|
||
showToast(toastMessage, 'success')
|
||
|
||
selectedAccounts.value = selectedAccounts.value.filter((id) => id !== account.id)
|
||
updateSelectAllState()
|
||
|
||
groupMembersLoaded.value = false
|
||
apiKeysLoaded.value = false
|
||
loadAccounts()
|
||
loadApiKeys(true)
|
||
} else {
|
||
showToast(result.message || '删除失败', 'error')
|
||
}
|
||
}
|
||
|
||
// 批量删除账户
|
||
const batchDeleteAccounts = async () => {
|
||
if (selectedAccounts.value.length === 0) {
|
||
showToast('请先选择要删除的账户', 'warning')
|
||
return
|
||
}
|
||
|
||
const accountsMap = new Map(accounts.value.map((item) => [item.id, item]))
|
||
const targets = selectedAccounts.value
|
||
.map((id) => accountsMap.get(id))
|
||
.filter((account) => !!account)
|
||
|
||
if (targets.length === 0) {
|
||
showToast('选中的账户已不存在', 'warning')
|
||
selectedAccounts.value = []
|
||
updateSelectAllState()
|
||
return
|
||
}
|
||
|
||
let confirmMessage = `确定要删除选中的 ${targets.length} 个账户吗?此操作不可恢复。`
|
||
const boundInfo = targets
|
||
.map((account) => ({ account, boundKeys: getBoundApiKeysForAccount(account) }))
|
||
.filter((item) => item.boundKeys.length > 0)
|
||
|
||
if (boundInfo.length > 0) {
|
||
confirmMessage += '\n\n⚠️ 以下账户存在绑定的 API Key,将自动解绑:'
|
||
boundInfo.forEach(({ account, boundKeys }) => {
|
||
const displayName = account.name || account.email || account.accountName || account.id
|
||
confirmMessage += `\n- ${displayName}: ${boundKeys.length} 个`
|
||
})
|
||
confirmMessage += '\n删除后,这些 API Key 将切换为共享池模式。'
|
||
}
|
||
|
||
confirmMessage += '\n\n请再次确认是否继续。'
|
||
|
||
const confirmed = await showConfirm('批量删除账户', confirmMessage, '删除', '取消')
|
||
if (!confirmed) return
|
||
|
||
let successCount = 0
|
||
let failedCount = 0
|
||
let totalUnboundKeys = 0
|
||
const failedDetails = []
|
||
|
||
for (const account of targets) {
|
||
const result = await performAccountDeletion(account)
|
||
if (result.success) {
|
||
successCount += 1
|
||
totalUnboundKeys += result.data?.unboundKeys || 0
|
||
} else {
|
||
failedCount += 1
|
||
failedDetails.push({
|
||
name: account.name || account.email || account.accountName || account.id,
|
||
message: result.message || '删除失败'
|
||
})
|
||
}
|
||
}
|
||
|
||
if (successCount > 0) {
|
||
let toastMessage = `成功删除 ${successCount} 个账户`
|
||
if (totalUnboundKeys > 0) {
|
||
toastMessage += `,${totalUnboundKeys} 个 API Key 已切换为共享池模式`
|
||
}
|
||
showToast(toastMessage, failedCount > 0 ? 'warning' : 'success')
|
||
|
||
selectedAccounts.value = []
|
||
selectAllChecked.value = false
|
||
isIndeterminate.value = false
|
||
|
||
groupMembersLoaded.value = false
|
||
apiKeysLoaded.value = false
|
||
await loadAccounts(true)
|
||
}
|
||
|
||
if (failedCount > 0) {
|
||
const detailMessage = failedDetails.map((item) => `${item.name}: ${item.message}`).join('\n')
|
||
showToast(
|
||
`有 ${failedCount} 个账户删除失败:\n${detailMessage}`,
|
||
successCount > 0 ? 'warning' : 'error'
|
||
)
|
||
}
|
||
|
||
updateSelectAllState()
|
||
}
|
||
|
||
// 重置账户状态
|
||
const resetAccountStatus = async (account) => {
|
||
if (account.isResetting) return
|
||
|
||
let confirmed = false
|
||
if (window.showConfirm) {
|
||
confirmed = await window.showConfirm(
|
||
'重置账户状态',
|
||
'确定要重置此账户的所有异常状态吗?这将清除限流状态、401错误计数等所有异常标记。',
|
||
'确定重置',
|
||
'取消'
|
||
)
|
||
} else {
|
||
confirmed = confirm('确定要重置此账户的所有异常状态吗?')
|
||
}
|
||
|
||
if (!confirmed) return
|
||
|
||
try {
|
||
account.isResetting = true
|
||
|
||
// 根据账户平台选择不同的 API 端点
|
||
let endpoint = ''
|
||
if (account.platform === 'openai') {
|
||
endpoint = `/admin/openai-accounts/${account.id}/reset-status`
|
||
} else if (account.platform === 'openai-responses') {
|
||
endpoint = `/admin/openai-responses-accounts/${account.id}/reset-status`
|
||
} else if (account.platform === 'claude') {
|
||
endpoint = `/admin/claude-accounts/${account.id}/reset-status`
|
||
} else if (account.platform === 'claude-console') {
|
||
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
|
||
} else if (account.platform === 'ccr') {
|
||
endpoint = `/admin/ccr-accounts/${account.id}/reset-status`
|
||
} else if (account.platform === 'droid') {
|
||
endpoint = `/admin/droid-accounts/${account.id}/reset-status`
|
||
} else {
|
||
showToast('不支持的账户类型', 'error')
|
||
account.isResetting = false
|
||
return
|
||
}
|
||
|
||
const data = await apiClient.post(endpoint)
|
||
|
||
if (data.success) {
|
||
showToast('账户状态已重置', 'success')
|
||
// 强制刷新,绕过前端缓存,确保最终一致性
|
||
loadAccounts(true)
|
||
} else {
|
||
showToast(data.message || '状态重置失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
showToast('状态重置失败', 'error')
|
||
} finally {
|
||
account.isResetting = false
|
||
}
|
||
}
|
||
|
||
// 切换调度状态
|
||
const toggleSchedulable = async (account) => {
|
||
if (account.isTogglingSchedulable) return
|
||
|
||
try {
|
||
account.isTogglingSchedulable = true
|
||
|
||
let endpoint
|
||
if (account.platform === 'claude') {
|
||
endpoint = `/admin/claude-accounts/${account.id}/toggle-schedulable`
|
||
} else if (account.platform === 'claude-console') {
|
||
endpoint = `/admin/claude-console-accounts/${account.id}/toggle-schedulable`
|
||
} else if (account.platform === 'bedrock') {
|
||
endpoint = `/admin/bedrock-accounts/${account.id}/toggle-schedulable`
|
||
} else if (account.platform === 'gemini') {
|
||
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable`
|
||
} else if (account.platform === 'openai') {
|
||
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
|
||
} else if (account.platform === 'azure_openai') {
|
||
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
|
||
} else if (account.platform === 'openai-responses') {
|
||
endpoint = `/admin/openai-responses-accounts/${account.id}/toggle-schedulable`
|
||
} else if (account.platform === 'ccr') {
|
||
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
|
||
} else if (account.platform === 'droid') {
|
||
endpoint = `/admin/droid-accounts/${account.id}/toggle-schedulable`
|
||
} else {
|
||
showToast('该账户类型暂不支持调度控制', 'warning')
|
||
return
|
||
}
|
||
|
||
const data = await apiClient.put(endpoint)
|
||
|
||
if (data.success) {
|
||
account.schedulable = data.schedulable
|
||
showToast(data.schedulable ? '已启用调度' : '已禁用调度', 'success')
|
||
} else {
|
||
showToast(data.message || '操作失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
showToast('切换调度状态失败', 'error')
|
||
} finally {
|
||
account.isTogglingSchedulable = false
|
||
}
|
||
}
|
||
|
||
// 处理创建成功
|
||
const handleCreateSuccess = () => {
|
||
showCreateAccountModal.value = false
|
||
showToast('账户创建成功', 'success')
|
||
// 清空缓存,因为可能涉及分组关系变化
|
||
clearCache()
|
||
loadAccounts()
|
||
}
|
||
|
||
// 处理编辑成功
|
||
const handleEditSuccess = () => {
|
||
showEditAccountModal.value = false
|
||
showToast('账户更新成功', 'success')
|
||
// 清空分组成员缓存,因为账户类型和分组可能发生变化
|
||
groupMembersLoaded.value = false
|
||
loadAccounts()
|
||
}
|
||
|
||
// 获取 Claude 账号的添加方式
|
||
const getClaudeAuthType = (account) => {
|
||
// 基于 lastRefreshAt 判断:如果为空说明是 Setup Token(不能刷新),否则是 OAuth
|
||
if (!account.lastRefreshAt || account.lastRefreshAt === '') {
|
||
return 'Setup' // 缩短显示文本
|
||
}
|
||
return 'OAuth'
|
||
}
|
||
|
||
// 获取 Gemini 账号的添加方式
|
||
const getGeminiAuthType = () => {
|
||
// Gemini 统一显示 OAuth
|
||
return 'OAuth'
|
||
}
|
||
|
||
// 获取 OpenAI 账号的添加方式
|
||
const getOpenAIAuthType = () => {
|
||
// OpenAI 统一显示 OAuth
|
||
return 'OAuth'
|
||
}
|
||
|
||
// 获取 Claude 账号类型显示
|
||
const getClaudeAccountType = (account) => {
|
||
// 如果有订阅信息
|
||
if (account.subscriptionInfo) {
|
||
try {
|
||
// 如果 subscriptionInfo 是字符串,尝试解析
|
||
const info =
|
||
typeof account.subscriptionInfo === 'string'
|
||
? JSON.parse(account.subscriptionInfo)
|
||
: account.subscriptionInfo
|
||
|
||
// 订阅信息已解析
|
||
|
||
// 根据 has_claude_max 和 has_claude_pro 判断
|
||
if (info.hasClaudeMax === true) {
|
||
return 'Claude Max'
|
||
} else if (info.hasClaudePro === true) {
|
||
return 'Claude Pro'
|
||
} else {
|
||
return 'Claude Free'
|
||
}
|
||
} catch (e) {
|
||
// 解析失败,返回默认值
|
||
return 'Claude'
|
||
}
|
||
}
|
||
|
||
// 没有订阅信息,保持原有显示
|
||
return 'Claude'
|
||
}
|
||
|
||
// 获取停止调度的原因
|
||
const getSchedulableReason = (account) => {
|
||
if (account.schedulable !== false) return null
|
||
|
||
// Claude Console 账户的错误状态
|
||
if (account.platform === 'claude-console') {
|
||
if (account.status === 'unauthorized') {
|
||
return 'API Key无效或已过期(401错误)'
|
||
}
|
||
if (account.overloadStatus === 'overloaded') {
|
||
return '服务过载(529错误)'
|
||
}
|
||
if (account.rateLimitStatus === 'limited') {
|
||
return '触发限流(429错误)'
|
||
}
|
||
if (account.status === 'blocked' && account.errorMessage) {
|
||
return account.errorMessage
|
||
}
|
||
}
|
||
|
||
// Claude 官方账户的错误状态
|
||
if (account.platform === 'claude') {
|
||
if (account.status === 'unauthorized') {
|
||
return '认证失败(401错误)'
|
||
}
|
||
if (account.status === 'temp_error' && account.errorMessage) {
|
||
return account.errorMessage
|
||
}
|
||
if (account.status === 'error' && account.errorMessage) {
|
||
return account.errorMessage
|
||
}
|
||
if (account.isRateLimited) {
|
||
return '触发限流(429错误)'
|
||
}
|
||
// 自动停止调度的原因
|
||
if (account.stoppedReason) {
|
||
return account.stoppedReason
|
||
}
|
||
// 检查5小时限制自动停止标志(备用方案)
|
||
if (account.fiveHourAutoStopped === 'true' || account.fiveHourAutoStopped === true) {
|
||
return '5小时使用量接近限制,已自动停止调度'
|
||
}
|
||
}
|
||
|
||
// OpenAI 账户的错误状态
|
||
if (account.platform === 'openai') {
|
||
if (account.status === 'unauthorized') {
|
||
return '认证失败(401错误)'
|
||
}
|
||
// 检查限流状态 - 兼容嵌套的 rateLimitStatus 对象
|
||
if (
|
||
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
|
||
account.isRateLimited
|
||
) {
|
||
return '触发限流(429错误)'
|
||
}
|
||
if (account.status === 'error' && account.errorMessage) {
|
||
return account.errorMessage
|
||
}
|
||
}
|
||
|
||
// OpenAI-Responses 账户的错误状态
|
||
if (account.platform === 'openai-responses') {
|
||
if (account.status === 'unauthorized') {
|
||
return '认证失败(401错误)'
|
||
}
|
||
// 检查限流状态 - 兼容嵌套的 rateLimitStatus 对象
|
||
if (
|
||
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
|
||
account.isRateLimited
|
||
) {
|
||
return '触发限流(429错误)'
|
||
}
|
||
if (account.status === 'error' && account.errorMessage) {
|
||
return account.errorMessage
|
||
}
|
||
if (account.status === 'rateLimited') {
|
||
return '触发限流(429错误)'
|
||
}
|
||
}
|
||
|
||
// 通用原因
|
||
if (account.stoppedReason) {
|
||
return account.stoppedReason
|
||
}
|
||
if (account.errorMessage) {
|
||
return account.errorMessage
|
||
}
|
||
|
||
// 默认为手动停止
|
||
return '手动停止调度'
|
||
}
|
||
|
||
// 获取账户状态文本
|
||
const getAccountStatusText = (account) => {
|
||
// 检查是否被封锁
|
||
if (account.status === 'blocked') return '已封锁'
|
||
// 检查是否未授权(401错误)
|
||
if (account.status === 'unauthorized') return '异常'
|
||
// 检查是否限流
|
||
if (
|
||
account.isRateLimited ||
|
||
account.status === 'rate_limited' ||
|
||
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
|
||
account.rateLimitStatus === 'limited'
|
||
)
|
||
return '限流中'
|
||
// 检查是否临时错误
|
||
if (account.status === 'temp_error') return '临时异常'
|
||
// 检查是否错误
|
||
if (account.status === 'error' || !account.isActive) return '错误'
|
||
// 检查是否可调度
|
||
if (account.schedulable === false) return '已暂停'
|
||
// 否则正常
|
||
return '正常'
|
||
}
|
||
|
||
// 获取账户状态样式类
|
||
const getAccountStatusClass = (account) => {
|
||
if (account.status === 'blocked') {
|
||
return 'bg-red-100 text-red-800'
|
||
}
|
||
if (account.status === 'unauthorized') {
|
||
return 'bg-red-100 text-red-800'
|
||
}
|
||
if (
|
||
account.isRateLimited ||
|
||
account.status === 'rate_limited' ||
|
||
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
|
||
account.rateLimitStatus === 'limited'
|
||
) {
|
||
return 'bg-orange-100 text-orange-800'
|
||
}
|
||
if (account.status === 'temp_error') {
|
||
return 'bg-orange-100 text-orange-800'
|
||
}
|
||
if (account.status === 'error' || !account.isActive) {
|
||
return 'bg-red-100 text-red-800'
|
||
}
|
||
if (account.schedulable === false) {
|
||
return 'bg-gray-100 text-gray-800'
|
||
}
|
||
return 'bg-green-100 text-green-800'
|
||
}
|
||
|
||
// 获取账户状态点样式类
|
||
const getAccountStatusDotClass = (account) => {
|
||
if (account.status === 'blocked') {
|
||
return 'bg-red-500'
|
||
}
|
||
if (account.status === 'unauthorized') {
|
||
return 'bg-red-500'
|
||
}
|
||
if (
|
||
account.isRateLimited ||
|
||
account.status === 'rate_limited' ||
|
||
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
|
||
account.rateLimitStatus === 'limited'
|
||
) {
|
||
return 'bg-orange-500'
|
||
}
|
||
if (account.status === 'temp_error') {
|
||
return 'bg-orange-500'
|
||
}
|
||
if (account.status === 'error' || !account.isActive) {
|
||
return 'bg-red-500'
|
||
}
|
||
if (account.schedulable === false) {
|
||
return 'bg-gray-500'
|
||
}
|
||
return 'bg-green-500'
|
||
}
|
||
|
||
// 获取会话窗口百分比
|
||
// const getSessionWindowPercentage = (account) => {
|
||
// if (!account.sessionWindow) return 100
|
||
// const { remaining, total } = account.sessionWindow
|
||
// if (!total || total === 0) return 100
|
||
// return Math.round((remaining / total) * 100)
|
||
// }
|
||
|
||
// 格式化相对时间
|
||
const formatRelativeTime = (dateString) => {
|
||
return formatLastUsed(dateString)
|
||
}
|
||
|
||
// 获取会话窗口进度条的样式类
|
||
const getSessionProgressBarClass = (status, account = null) => {
|
||
// 根据状态返回不同的颜色类,包含防御性检查
|
||
if (!status) {
|
||
// 无状态信息时默认为蓝色
|
||
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
|
||
}
|
||
|
||
// 检查账号是否处于限流状态
|
||
const isRateLimited =
|
||
account &&
|
||
(account.isRateLimited ||
|
||
account.status === 'rate_limited' ||
|
||
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
|
||
account.rateLimitStatus === 'limited')
|
||
|
||
// 如果账号处于限流状态,显示红色
|
||
if (isRateLimited) {
|
||
return 'bg-gradient-to-r from-red-500 to-red-600'
|
||
}
|
||
|
||
// 转换为小写进行比较,避免大小写问题
|
||
const normalizedStatus = String(status).toLowerCase()
|
||
|
||
if (normalizedStatus === 'rejected') {
|
||
// 被拒绝 - 红色
|
||
return 'bg-gradient-to-r from-red-500 to-red-600'
|
||
} else if (normalizedStatus === 'allowed_warning') {
|
||
// 警告状态 - 橙色/黄色
|
||
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
|
||
} else {
|
||
// 正常状态(allowed 或其他) - 蓝色
|
||
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
|
||
}
|
||
}
|
||
|
||
// ====== Claude OAuth Usage 相关函数 ======
|
||
|
||
// 判断 Claude 账户是否为 OAuth 授权
|
||
const isClaudeOAuth = (account) => {
|
||
return account.authType === 'oauth'
|
||
}
|
||
|
||
// 格式化 Claude 使用率百分比
|
||
const formatClaudeUsagePercent = (window) => {
|
||
if (!window || window.utilization === null || window.utilization === undefined) {
|
||
return '-'
|
||
}
|
||
return `${window.utilization}%`
|
||
}
|
||
|
||
// 获取 Claude 使用率宽度
|
||
const getClaudeUsageWidth = (window) => {
|
||
if (!window || window.utilization === null || window.utilization === undefined) {
|
||
return '0%'
|
||
}
|
||
return `${window.utilization}%`
|
||
}
|
||
|
||
// 获取 Claude 使用率进度条颜色
|
||
const getClaudeUsageBarClass = (window) => {
|
||
const util = window?.utilization || 0
|
||
if (util < 60) {
|
||
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
|
||
}
|
||
if (util < 90) {
|
||
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
|
||
}
|
||
return 'bg-gradient-to-r from-red-500 to-red-600'
|
||
}
|
||
|
||
// 格式化 Claude 剩余时间
|
||
const formatClaudeRemaining = (window) => {
|
||
if (!window || !window.remainingSeconds) {
|
||
return '-'
|
||
}
|
||
|
||
const seconds = window.remainingSeconds
|
||
const days = Math.floor(seconds / 86400)
|
||
const hours = Math.floor((seconds % 86400) / 3600)
|
||
const minutes = Math.floor((seconds % 3600) / 60)
|
||
|
||
if (days > 0) {
|
||
if (hours > 0) {
|
||
return `${days}天${hours}小时`
|
||
}
|
||
return `${days}天`
|
||
}
|
||
if (hours > 0) {
|
||
if (minutes > 0) {
|
||
return `${hours}小时${minutes}分钟`
|
||
}
|
||
return `${hours}小时`
|
||
}
|
||
if (minutes > 0) {
|
||
return `${minutes}分钟`
|
||
}
|
||
return `${Math.floor(seconds % 60)}秒`
|
||
}
|
||
|
||
// 归一化 OpenAI 会话窗口使用率
|
||
const normalizeCodexUsagePercent = (usageItem) => {
|
||
if (!usageItem) {
|
||
return null
|
||
}
|
||
|
||
const basePercent =
|
||
typeof usageItem.usedPercent === 'number' && !Number.isNaN(usageItem.usedPercent)
|
||
? usageItem.usedPercent
|
||
: null
|
||
|
||
const resetAfterSeconds =
|
||
typeof usageItem.resetAfterSeconds === 'number' && !Number.isNaN(usageItem.resetAfterSeconds)
|
||
? usageItem.resetAfterSeconds
|
||
: null
|
||
|
||
const remainingSeconds =
|
||
typeof usageItem.remainingSeconds === 'number' ? usageItem.remainingSeconds : null
|
||
|
||
const resetAtMs = usageItem.resetAt ? Date.parse(usageItem.resetAt) : null
|
||
|
||
const resetElapsed =
|
||
resetAfterSeconds !== null &&
|
||
((remainingSeconds !== null && remainingSeconds <= 0) ||
|
||
(resetAtMs !== null && !Number.isNaN(resetAtMs) && Date.now() >= resetAtMs))
|
||
|
||
if (resetElapsed) {
|
||
return 0
|
||
}
|
||
|
||
if (basePercent === null) {
|
||
return null
|
||
}
|
||
|
||
return Math.max(0, Math.min(100, basePercent))
|
||
}
|
||
|
||
// OpenAI 限额进度条颜色
|
||
const getCodexUsageBarClass = (usageItem) => {
|
||
const percent = normalizeCodexUsagePercent(usageItem)
|
||
if (percent === null) {
|
||
return 'bg-gradient-to-r from-gray-300 to-gray-400'
|
||
}
|
||
if (percent >= 90) {
|
||
return 'bg-gradient-to-r from-red-500 to-red-600'
|
||
}
|
||
if (percent >= 75) {
|
||
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
|
||
}
|
||
return 'bg-gradient-to-r from-emerald-500 to-teal-500'
|
||
}
|
||
|
||
// 百分比显示
|
||
const formatCodexUsagePercent = (usageItem) => {
|
||
const percent = normalizeCodexUsagePercent(usageItem)
|
||
if (percent === null) {
|
||
return '--'
|
||
}
|
||
return `${percent.toFixed(1)}%`
|
||
}
|
||
|
||
// 进度条宽度
|
||
const getCodexUsageWidth = (usageItem) => {
|
||
const percent = normalizeCodexUsagePercent(usageItem)
|
||
if (percent === null) {
|
||
return '0%'
|
||
}
|
||
return `${percent}%`
|
||
}
|
||
|
||
// 时间窗口标签
|
||
const getCodexWindowLabel = (type) => {
|
||
if (type === 'secondary') {
|
||
return '周限'
|
||
}
|
||
return '5h'
|
||
}
|
||
|
||
// 格式化剩余时间
|
||
const formatCodexRemaining = (usageItem) => {
|
||
if (!usageItem) {
|
||
return '--'
|
||
}
|
||
|
||
let seconds = usageItem.remainingSeconds
|
||
if (seconds === null || seconds === undefined) {
|
||
seconds = usageItem.resetAfterSeconds
|
||
}
|
||
|
||
if (seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) {
|
||
return '--'
|
||
}
|
||
|
||
seconds = Math.max(0, Math.floor(Number(seconds)))
|
||
|
||
const days = Math.floor(seconds / 86400)
|
||
const hours = Math.floor((seconds % 86400) / 3600)
|
||
const minutes = Math.floor((seconds % 3600) / 60)
|
||
const secs = seconds % 60
|
||
|
||
if (days > 0) {
|
||
if (hours > 0) {
|
||
return `${days}天${hours}小时`
|
||
}
|
||
return `${days}天`
|
||
}
|
||
if (hours > 0) {
|
||
if (minutes > 0) {
|
||
return `${hours}小时${minutes}分钟`
|
||
}
|
||
return `${hours}小时`
|
||
}
|
||
if (minutes > 0) {
|
||
return `${minutes}分钟`
|
||
}
|
||
return `${secs}秒`
|
||
}
|
||
|
||
// 格式化费用显示
|
||
const formatCost = (cost) => {
|
||
if (!cost || cost === 0) return '0.0000'
|
||
if (cost < 0.0001) return cost.toExponential(2)
|
||
if (cost < 0.01) return cost.toFixed(6)
|
||
if (cost < 1) return cost.toFixed(4)
|
||
return cost.toFixed(2)
|
||
}
|
||
|
||
// 额度使用百分比(Claude Console)
|
||
const getQuotaUsagePercent = (account) => {
|
||
const used = Number(account?.usage?.daily?.cost || 0)
|
||
const quota = Number(account?.dailyQuota || 0)
|
||
if (!quota || quota <= 0) return 0
|
||
return (used / quota) * 100
|
||
}
|
||
|
||
// 额度进度条颜色(Claude Console)
|
||
const getQuotaBarClass = (percent) => {
|
||
if (percent >= 90) return 'bg-red-500'
|
||
if (percent >= 70) return 'bg-yellow-500'
|
||
return 'bg-green-500'
|
||
}
|
||
|
||
// 剩余额度(Claude Console)
|
||
const formatRemainingQuota = (account) => {
|
||
const used = Number(account?.usage?.daily?.cost || 0)
|
||
const quota = Number(account?.dailyQuota || 0)
|
||
if (!quota || quota <= 0) return '0.00'
|
||
return Math.max(0, quota - used).toFixed(2)
|
||
}
|
||
|
||
// 计算每日费用(使用后端返回的精确费用数据)
|
||
const calculateDailyCost = (account) => {
|
||
if (!account.usage || !account.usage.daily) return '0.0000'
|
||
|
||
// 如果后端已经返回了计算好的费用,直接使用
|
||
if (account.usage.daily.cost !== undefined) {
|
||
return formatCost(account.usage.daily.cost)
|
||
}
|
||
|
||
// 如果后端没有返回费用(旧版本),返回0
|
||
return '0.0000'
|
||
}
|
||
|
||
// 切换调度状态
|
||
// const toggleDispatch = async (account) => {
|
||
// await toggleSchedulable(account)
|
||
// }
|
||
|
||
watch(searchKeyword, () => {
|
||
currentPage.value = 1
|
||
updateSelectAllState()
|
||
})
|
||
|
||
watch(pageSize, (newSize) => {
|
||
localStorage.setItem(PAGE_SIZE_STORAGE_KEY, newSize.toString())
|
||
updateSelectAllState()
|
||
})
|
||
|
||
watch(
|
||
() => sortedAccounts.value.length,
|
||
() => {
|
||
if (currentPage.value > totalPages.value) {
|
||
currentPage.value = totalPages.value || 1
|
||
}
|
||
updateSelectAllState()
|
||
}
|
||
)
|
||
|
||
// 监听排序选择变化
|
||
watch(accountSortBy, (newVal) => {
|
||
const fieldMap = {
|
||
name: 'name',
|
||
dailyTokens: 'dailyTokens',
|
||
dailyRequests: 'dailyRequests',
|
||
totalTokens: 'totalTokens',
|
||
lastUsed: 'lastUsed'
|
||
}
|
||
|
||
if (fieldMap[newVal]) {
|
||
sortAccounts(fieldMap[newVal])
|
||
}
|
||
})
|
||
|
||
watch(currentPage, () => {
|
||
updateSelectAllState()
|
||
})
|
||
|
||
watch(paginatedAccounts, () => {
|
||
updateSelectAllState()
|
||
})
|
||
|
||
watch(accounts, () => {
|
||
cleanupSelectedAccounts()
|
||
})
|
||
|
||
onMounted(() => {
|
||
// 首次加载时强制刷新所有数据
|
||
loadAccounts(true)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.table-container {
|
||
overflow-x: auto;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.table-row {
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.table-row:hover {
|
||
background-color: rgba(0, 0, 0, 0.02);
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 24px;
|
||
height: 24px;
|
||
border: 2px solid #e5e7eb;
|
||
border-top: 2px solid #3b82f6;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
.accounts-container {
|
||
min-height: calc(100vh - 300px);
|
||
}
|
||
|
||
.table-container {
|
||
overflow-x: auto;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.table-row {
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.table-row:hover {
|
||
background-color: rgba(0, 0, 0, 0.02);
|
||
}
|
||
</style>
|