Files
sub2api/frontend/src/views/admin/AccountsView.vue
T
yan9651688 60ff61132d feat(accounts): make repeated static account setup safer
Admins often need another account with the same provider and routing configuration. Duplicate on the server so credentials never return to the browser, preserve exact group priorities atomically, start the copy paused, and recover the same copy after ambiguous idempotency-store failures.

Constraint: Admin account responses redact credentials, so duplication must remain server-side

Constraint: OAuth and setup-token credentials rotate and must not be shared across account rows

Rejected: Copy raw account JSON to the clipboard | exposes credentials outside the server

Rejected: Duplicate rotating credentials | account-scoped refresh locks can race token rotation

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep copies paused, avoid automatic upstream probes, and exclude rotating credential types unless token ownership is redesigned

Tested: Targeted Go tests, Go vet, server build; frontend lint, typecheck, Vitest suite, production build; integration test compiled

Not-tested: Docker-backed PostgreSQL execution because Docker is unavailable

Related: Wei-Shaw/sub2api#1379

Related: Wei-Shaw/sub2api#2928
2026-07-15 10:51:13 +08:00

1921 lines
79 KiB
Vue

<template>
<AppLayout>
<TablePageLayout>
<template #filters>
<div class="flex flex-wrap-reverse items-start justify-between gap-3">
<AccountTableFilters
v-model:searchQuery="params.search"
:filters="params"
:groups="groups"
@update:filters="(newFilters) => Object.assign(params, newFilters)"
@change="debouncedReload"
@update:searchQuery="debouncedReload"
/>
<AccountTableActions
:loading="loading"
@refresh="handleManualRefresh"
@create="showCreate = true"
>
<template #after>
<!-- Auto Refresh Dropdown -->
<div class="relative" ref="autoRefreshDropdownRef">
<button
@click="
showAutoRefreshDropdown = !showAutoRefreshDropdown;
showAccountToolsDropdown = false
"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.accounts.autoRefresh')"
>
<Icon name="refresh" size="sm" :class="[autoRefreshEnabled ? 'animate-spin' : '']" />
<span class="hidden md:inline">
{{
autoRefreshEnabled
? t('admin.accounts.autoRefreshCountdown', { seconds: autoRefreshCountdown })
: t('admin.accounts.autoRefresh')
}}
</span>
</button>
<div
v-if="showAutoRefreshDropdown"
class="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
<div class="p-2">
<button
@click="setAutoRefreshEnabled(!autoRefreshEnabled)"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>{{ t('admin.accounts.enableAutoRefresh') }}</span>
<Icon v-if="autoRefreshEnabled" name="check" size="sm" class="text-primary-500" />
</button>
<div class="my-1 border-t border-gray-100 dark:border-gray-700"></div>
<button
v-for="sec in autoRefreshIntervals"
:key="sec"
@click="setAutoRefreshInterval(sec)"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>{{ autoRefreshIntervalLabel(sec) }}</span>
<Icon v-if="autoRefreshIntervalSeconds === sec" name="check" size="sm" class="text-primary-500" />
</button>
</div>
</div>
</div>
<!-- More Tools Dropdown -->
<div class="relative" ref="accountToolsDropdownRef">
<button
@click="
showAccountToolsDropdown = !showAccountToolsDropdown;
showAutoRefreshDropdown = false
"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.accounts.moreActions')"
>
<Icon name="more" size="sm" class="md:mr-1.5" />
<span class="hidden md:inline">{{ t('admin.accounts.moreActions') }}</span>
<Icon name="chevronDown" size="xs" class="ml-1 hidden md:inline" />
</button>
<div
v-if="showAccountToolsDropdown"
class="absolute right-0 z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] origin-top-right overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
>
<div class="max-h-[70vh] overflow-y-auto p-2">
<div class="px-2 py-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.dataActions') }}
</div>
</div>
<button class="account-tools-menu-item" @click="openSyncFromCrs">
<span class="account-tools-menu-icon bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300">
<Icon name="sync" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.accounts.syncFromCrs') }}</span>
</button>
<button class="account-tools-menu-item" @click="openImportData">
<span class="account-tools-menu-icon bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300">
<Icon name="upload" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.accounts.dataImport') }}</span>
</button>
<button class="account-tools-menu-item" @click="openExportDataDialogFromMenu">
<span class="account-tools-menu-icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-300">
<Icon name="download" size="sm" />
</span>
<span class="flex-1 text-left">
{{ selIds.length ? t('admin.accounts.dataExportSelected') : t('admin.accounts.dataExport') }}
</span>
<span
v-if="selIds.length"
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{{ t('admin.accounts.selectedCount', { count: selIds.length }) }}
</span>
</button>
<div class="my-2 border-t border-gray-100 dark:border-gray-700"></div>
<div class="px-2 py-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.toolActions') }}
</div>
</div>
<button class="account-tools-menu-item" @click="openErrorPassthrough">
<span class="account-tools-menu-icon bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-300">
<Icon name="shield" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.errorPassthrough.title') }}</span>
</button>
<button class="account-tools-menu-item" @click="openTLSFingerprintProfiles">
<span class="account-tools-menu-icon bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-200">
<Icon name="lock" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.tlsFingerprintProfiles.title') }}</span>
</button>
<div class="my-2 border-t border-gray-100 dark:border-gray-700"></div>
<div class="px-2 py-2">
<div class="flex items-center justify-between gap-3">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.viewColumns') }}
</span>
<Icon name="grid" size="sm" class="text-gray-400" />
</div>
</div>
<div class="grid grid-cols-1 gap-1">
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span class="truncate">{{ col.label }}</span>
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
</button>
</div>
</div>
</div>
</div>
</template>
</AccountTableActions>
</div>
<div
v-if="hasPendingListSync"
class="mt-2 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200"
>
<span>{{ t('admin.accounts.listPendingSyncHint') }}</span>
<button
class="btn btn-secondary px-2 py-1 text-xs"
@click="syncPendingListChanges"
>
{{ t('admin.accounts.listPendingSyncAction') }}
</button>
</div>
</template>
<template #table>
<AccountBulkActionsBar
:selected-ids="selIds"
@delete="handleBulkDelete"
@reset-status="handleBulkResetStatus"
@refresh-token="handleBulkRefreshToken"
@edit-selected="openBulkEditSelected"
@edit-filtered="openBulkEditFiltered"
@clear="clearSelection"
@select-page="selectPage"
@toggle-schedulable="handleBulkToggleSchedulable"
/>
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataTable
ref="dataTableRef"
:columns="cols"
:data="accounts"
:loading="loading"
row-key="id"
:server-side-sort="true"
@sort="handleSort"
default-sort-key="name"
default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
:estimate-row-height="156"
:overscan="5"
:virtualize-threshold="50"
>
<template #header-select>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:checked="allVisibleSelected"
@click.stop
@change="toggleSelectAllVisible($event)"
/>
</template>
<template #cell-select="{ row }">
<input type="checkbox" :checked="isSelected(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</template>
<template #cell-id="{ value }">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">#{{ value }}</span>
</template>
<template #cell-name="{ row, value }">
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<span
v-if="accountDisplayEmail(row)"
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
:title="accountDisplayEmail(row) + (row.parent_chatgpt_account_id ? ' · ' + row.parent_chatgpt_account_id : '')"
>
{{ accountDisplayEmail(row) }}
</span>
</div>
</template>
<template #cell-notes="{ value }">
<span v-if="value" :title="value" class="block max-w-xs truncate text-sm text-gray-600 dark:text-gray-300">{{ value }}</span>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<template #cell-platform_type="{ row }">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex flex-wrap items-center gap-1">
<PlatformTypeBadge :platform="row.platform" :type="row.type"
:auth-mode="getOpenAIAuthMode(row)"
:plan-type="getAccountPlanType(row)"
:privacy-mode="row.extra?.privacy_mode || row.parent_privacy_mode"
:subscription-expires-at="row.credentials?.subscription_expires_at || row.parent_subscription_expires_at" />
<span
v-if="getAntigravityTierLabel(row)"
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]"
>
{{ getAntigravityTierLabel(row) }}
</span>
</div>
<div
v-if="getOpenAICompactMeta(row)"
:class="[
'inline-flex items-center gap-1.5 pl-0.5 text-[11px] font-medium leading-4',
getOpenAICompactMeta(row)?.className
]"
:title="getOpenAICompactTitle(row)"
>
<span :class="['h-1.5 w-1.5 rounded-full', getOpenAICompactMeta(row)?.dotClass]" />
<span>{{ getOpenAICompactMeta(row)?.label }}</span>
</div>
</div>
</template>
<template #cell-capacity="{ row }">
<AccountCapacityCell :account="row" />
</template>
<template #cell-status="{ row }">
<div class="flex items-center gap-1.5">
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
</div>
</template>
<template #cell-schedulable="{ row }">
<button @click="handleToggleSchedulable(row)" :disabled="togglingSchedulable === row.id" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800" :class="[row.schedulable ? 'bg-primary-500 hover:bg-primary-600' : 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500']" :title="row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')">
<span class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" :class="[row.schedulable ? 'translate-x-4' : 'translate-x-0']" />
</button>
</template>
<template #cell-today_stats="{ row }">
<AccountTodayStatsCell
:stats="todayStatsByAccountId[String(row.id)] ?? null"
:loading="todayStatsLoading"
:error="todayStatsError"
/>
</template>
<template #cell-groups="{ row }">
<AccountGroupsCell :groups="row.groups" :max-display="4" />
</template>
<template #header-usage="{ column }">
<div class="flex items-center">
<span>{{ column.label }}</span>
<HelpTooltip :content="t('admin.accounts.usageWindowsHint')" width-class="w-72" />
</div>
</template>
<template #cell-usage="{ row }">
<AccountUsageCell
:account="row"
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
:today-stats-loading="todayStatsLoading"
:manual-refresh-token="usageManualRefreshToken"
/>
</template>
<template #cell-proxy="{ row }">
<div class="flex flex-col gap-1">
<div v-if="row.proxy" class="flex items-center gap-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ row.proxy.name }}</span>
<span v-if="row.proxy.country_code" class="text-xs text-gray-500 dark:text-gray-400">
({{ row.proxy.country_code }})
</span>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
<div v-if="row.proxy && row.proxy.expires_at" class="flex items-center gap-2 text-xs">
<span class="text-gray-600 dark:text-gray-300">{{ formatDateTime(row.proxy.expires_at) }}</span>
<span :class="proxyExpiryBadge(row.proxy)">{{ proxyExpiryText(row.proxy) }}</span>
</div>
<div v-if="row.proxy_fallback_origin_id" class="flex items-center gap-1">
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" :title="t('admin.accounts.fallbackActiveTip', { origin: row.proxy_fallback_origin_name })">
{{ t('admin.accounts.fallbackActive') }}
</span>
<button class="text-xs px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" @click="onRevertFallback(row)">{{ t('admin.accounts.revertProxy') }}</button>
</div>
</div>
</template>
<template #cell-rate_multiplier="{ row }">
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
</span>
</template>
<template #cell-priority="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
</template>
<template #header-scheduler_score="{ column }">
<div class="flex items-center">
<span>{{ column.label }}</span>
<HelpTooltip :content="t('admin.accounts.schedulerScore.hint')" width-class="w-80" />
</div>
</template>
<template #cell-scheduler_score="{ row }">
<div v-if="getSchedulerScoreRows(row).length" class="flex min-w-[7rem] flex-col gap-0.5 font-mono text-[11px] leading-4">
<div
v-for="score in getSchedulerScoreRows(row)"
:key="String(score.group_id)"
class="flex items-center gap-1 whitespace-nowrap text-gray-700 dark:text-gray-300"
:title="`${formatSchedulerScoreGroup(score)} / ${formatSchedulerScore(score.base_score)} / ${formatStickySchedulerScore(score)}`"
>
<span class="max-w-[4.75rem] truncate text-gray-500 dark:text-dark-400">{{ formatSchedulerScoreGroup(score) }}</span>
<span class="text-gray-300 dark:text-gray-600">/</span>
<span>{{ formatSchedulerScore(score.base_score) }}</span>
<span class="text-gray-300 dark:text-gray-600">/</span>
<span class="text-primary-700 dark:text-primary-300">{{ formatStickySchedulerScore(score) }}</span>
</div>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<template #cell-last_used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-expires_at="{ row, value }">
<div class="flex flex-col items-start gap-1">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatExpiresAt(value) }}</span>
<div v-if="isExpired(value) || (row.auto_pause_on_expired && value)" class="flex items-center gap-1">
<span
v-if="isExpired(value)"
class="inline-flex items-center rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.expired') }}
</span>
<span
v-if="row.auto_pause_on_expired && value"
class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
>
{{ t('admin.accounts.autoPauseOnExpired') }}
</span>
</div>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /></svg>
<span class="text-xs">{{ t('common.edit') }}</span>
</button>
<button @click="handleDelete(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>
<span class="text-xs">{{ t('common.delete') }}</span>
</button>
<button @click="openMenu(row, $event)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" /></svg>
<span class="text-xs">{{ t('common.more') }}</span>
</button>
</div>
</template>
</DataTable>
</div>
</template>
<template #pagination><Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" /></template>
</TablePageLayout>
<CreateAccountModal :show="showCreate" :proxies="proxies" :groups="groups" @close="showCreate = false" @created="reload" />
<EditAccountModal :show="showEdit" :account="edAcc" :proxies="proxies" :groups="groups" @close="showEdit = false" @updated="handleAccountUpdated" />
<ReAuthAccountModal :show="showReAuth" :account="reAuthAcc" @close="closeReAuthModal" @reauthorized="handleAccountUpdated" />
<AccountTestModal :show="showTest" :account="testingAcc" @close="closeTestModal" />
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
<ScheduledTestsPanel :show="showSchedulePanel" :account-id="scheduleAcc?.id ?? null" :model-options="scheduleModelOptions" @close="closeSchedulePanel" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @duplicate="handleDuplicateAccount" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" @create-spark-shadow="handleCreateSparkShadow" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal
:show="showBulkEdit"
:account-ids="selIds"
:selected-platforms="selPlatforms"
:selected-types="selTypes"
:target="bulkEditTarget ?? undefined"
:proxies="proxies"
:groups="groups"
@close="showBulkEdit = false"
@updated="handleBulkUpdated"
/>
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
<ConfirmDialog :show="showCreateShadowDialog" :title="t('admin.accounts.createSparkShadow')" :message="t('admin.accounts.createSparkShadowConfirm', { name: creatingShadowAcc?.name })" @confirm="confirmCreateSparkShadow" @cancel="showCreateShadowDialog = false" />
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" v-model="includeProxyOnExport" />
<span>{{ t('admin.accounts.dataExportIncludeProxies') }}</span>
</label>
</ConfirmDialog>
<ErrorPassthroughRulesModal :show="showErrorPassthrough" @close="showErrorPassthrough = false" />
<TLSFingerprintProfilesModal :show="showTLSFingerprintProfiles" @close="showTLSFingerprintProfiles = false" />
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, toRaw, watch } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import { useTableLoader } from '@/composables/useTableLoader'
import { useSwipeSelect, type SwipeSelectVirtualContext } from '@/composables/useSwipeSelect'
import { useTableSelection } from '@/composables/useTableSelection'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import { CreateAccountModal, EditAccountModal, BulkEditAccountModal, SyncFromCrsModal, TempUnschedStatusModal } from '@/components/account'
import AccountTableActions from '@/components/admin/account/AccountTableActions.vue'
import AccountTableFilters from '@/components/admin/account/AccountTableFilters.vue'
import AccountBulkActionsBar from '@/components/admin/account/AccountBulkActionsBar.vue'
import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue'
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
import ReAuthAccountModal from '@/components/admin/account/ReAuthAccountModal.vue'
import AccountTestModal from '@/components/admin/account/AccountTestModal.vue'
import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
import ScheduledTestsPanel from '@/components/admin/account/ScheduledTestsPanel.vue'
import type { SelectOption } from '@/components/common/Select.vue'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import Icon from '@/components/icons/Icon.vue'
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfilesModal.vue'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const proxies = ref<AccountProxy[]>([])
const groups = ref<AdminGroup[]>([])
const accountTableRef = ref<HTMLElement | null>(null)
const dataTableRef = ref<InstanceType<typeof DataTable> | null>(null)
type AccountBulkEditTarget =
| {
mode: 'selected'
accountIds: number[]
selectedPlatforms: AccountPlatform[]
selectedTypes: AccountType[]
}
| {
mode: 'filtered'
filters: {
platform?: string
type?: string
status?: string
group?: string
search?: string
privacy_mode?: string
sort_by?: string
sort_order?: AccountSortOrder
}
previewCount: number
selectedPlatforms: AccountPlatform[]
selectedTypes: AccountType[]
}
const selPlatforms = computed<AccountPlatform[]>(() => {
const platforms = new Set(
accounts.value
.filter(a => isSelected(a.id))
.map(a => a.platform)
)
return [...platforms]
})
const selTypes = computed<AccountType[]>(() => {
const types = new Set(
accounts.value
.filter(a => isSelected(a.id))
.map(a => a.type)
)
return [...types]
})
const showCreate = ref(false)
const showEdit = ref(false)
const showSync = ref(false)
const showImportData = ref(false)
const showExportDataDialog = ref(false)
const includeProxyOnExport = ref(true)
const showBulkEdit = ref(false)
const bulkEditTarget = ref<AccountBulkEditTarget | null>(null)
const showTempUnsched = ref(false)
const showDeleteDialog = ref(false)
const showCreateShadowDialog = ref(false)
const showReAuth = ref(false)
const showTest = ref(false)
const showStats = ref(false)
const showErrorPassthrough = ref(false)
const showTLSFingerprintProfiles = ref(false)
const edAcc = ref<Account | null>(null)
const tempUnschedAcc = ref<Account | null>(null)
const deletingAcc = ref<Account | null>(null)
const creatingShadowAcc = ref<Account | null>(null)
const reAuthAcc = ref<Account | null>(null)
const testingAcc = ref<Account | null>(null)
const statsAcc = ref<Account | null>(null)
const showSchedulePanel = ref(false)
const scheduleAcc = ref<Account | null>(null)
const scheduleModelOptions = ref<SelectOption[]>([])
const togglingSchedulable = ref<number | null>(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const exportingData = ref(false)
// Account tools dropdown
const showAccountToolsDropdown = ref(false)
const accountToolsDropdownRef = ref<HTMLElement | null>(null)
const hiddenColumns = reactive<Set<string>>(new Set())
const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'scheduler_score', 'rate_multiplier']
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// One-time migration: hide scheduler score for existing admins too, because showing it opt-ins to heavy backend scoring.
const HIDDEN_COLUMNS_VERSION_KEY = 'account-hidden-columns-version'
const HIDDEN_COLUMNS_CURRENT_VERSION = 'scheduler-score-hidden-by-default'
// Sorting settings
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
type AccountSortOrder = 'asc' | 'desc'
type AccountSortState = {
sort_by: string
sort_order: AccountSortOrder
}
const ACCOUNT_SORTABLE_KEYS = new Set([
'id',
'name',
'status',
'schedulable',
'priority',
'rate_multiplier',
'last_used_at',
'created_at',
'expires_at'
])
const loadInitialAccountSortState = (): AccountSortState => {
const fallback: AccountSortState = { sort_by: 'name', sort_order: 'asc' }
try {
const raw = localStorage.getItem(ACCOUNT_SORT_STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as { key?: string; order?: string }
const key = typeof parsed.key === 'string' ? parsed.key : ''
if (!ACCOUNT_SORTABLE_KEYS.has(key)) return fallback
return {
sort_by: key,
sort_order: parsed.order === 'desc' ? 'desc' : 'asc'
}
} catch {
return fallback
}
}
const sortState = reactive<AccountSortState>(loadInitialAccountSortState())
// Auto refresh settings
const showAutoRefreshDropdown = ref(false)
const autoRefreshDropdownRef = ref<HTMLElement | null>(null)
const AUTO_REFRESH_STORAGE_KEY = 'account-auto-refresh'
const autoRefreshIntervals = [5, 10, 15, 30] as const
const autoRefreshEnabled = ref(false)
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
const autoRefreshCountdown = ref(0)
const autoRefreshETag = ref<string | null>(null)
const autoRefreshFetching = ref(false)
const AUTO_REFRESH_SILENT_WINDOW_MS = 15000
const autoRefreshSilentUntil = ref(0)
const hasPendingListSync = ref(false)
const todayStatsByAccountId = ref<Record<string, WindowStats>>({})
const todayStatsLoading = ref(false)
const todayStatsError = ref<string | null>(null)
const todayStatsReqSeq = ref(0)
const pendingTodayStatsRefresh = ref(false)
const usageManualRefreshToken = ref(0)
const buildDefaultTodayStats = (): WindowStats => ({
requests: 0,
tokens: 0,
cost: 0,
standard_cost: 0,
user_cost: 0
})
const refreshTodayStatsBatch = async () => {
// Why this checks both columns:
// - today_stats column shows dedicated today's metrics.
// - usage column also embeds today's stats for Key/Bedrock rows.
// So we only skip fetching when BOTH columns are hidden.
if (hiddenColumns.has('today_stats') && hiddenColumns.has('usage')) {
todayStatsLoading.value = false
todayStatsError.value = null
return
}
const accountIDs = accounts.value.map(account => account.id)
const reqSeq = ++todayStatsReqSeq.value
if (accountIDs.length === 0) {
todayStatsByAccountId.value = {}
todayStatsError.value = null
todayStatsLoading.value = false
return
}
todayStatsLoading.value = true
todayStatsError.value = null
try {
const result = await adminAPI.accounts.getBatchTodayStats(accountIDs)
if (reqSeq !== todayStatsReqSeq.value) return
const serverStats = result.stats ?? {}
const nextStats: Record<string, WindowStats> = {}
for (const accountID of accountIDs) {
const key = String(accountID)
nextStats[key] = serverStats[key] ?? buildDefaultTodayStats()
}
todayStatsByAccountId.value = nextStats
} catch (error) {
if (reqSeq !== todayStatsReqSeq.value) return
todayStatsError.value = 'Failed'
console.error('Failed to load account today stats:', error)
} finally {
if (reqSeq === todayStatsReqSeq.value) {
todayStatsLoading.value = false
}
}
}
const autoRefreshIntervalLabel = (sec: number) => {
if (sec === 5) return t('admin.accounts.refreshInterval5s')
if (sec === 10) return t('admin.accounts.refreshInterval10s')
if (sec === 15) return t('admin.accounts.refreshInterval15s')
if (sec === 30) return t('admin.accounts.refreshInterval30s')
return `${sec}s`
}
const formatSchedulerScore = (value: unknown): string => {
const num = Number(value)
if (!Number.isFinite(num)) return '-'
return num.toFixed(6).replace(/\.?0+$/, '')
}
const formatStickySchedulerScore = (score: AccountSchedulerGroupScore): string => {
if (!score) return '-'
if (score.sticky_score_infinity) return '+∞'
return formatSchedulerScore(score.sticky_score)
}
const getSchedulerScoreRows = (account: Account): AccountSchedulerGroupScore[] => {
const groupRows = Array.isArray(account.scheduler_scores)
? account.scheduler_scores.filter(score => score.group_id != null)
: []
if (groupRows.length) return groupRows
// 未分组账号没有分组维度分数,回退展示后端返回的基础分
if (account.scheduler_score) {
return [{ group_id: null, ...account.scheduler_score }]
}
return []
}
const formatSchedulerScoreGroup = (score: AccountSchedulerGroupScore): string => {
if ('group_name' in score && score.group_name) return score.group_name
if ('group_id' in score && score.group_id != null) return `#${score.group_id}`
return t('admin.accounts.schedulerScore.ungrouped')
}
const loadSavedColumns = () => {
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
if (saved) {
const parsed = JSON.parse(saved) as string[]
parsed.forEach(key => {
hiddenColumns.add(key)
})
// Older saved column layouts may have scheduler_score visible; migrate them to the new safe default once.
if (localStorage.getItem(HIDDEN_COLUMNS_VERSION_KEY) !== HIDDEN_COLUMNS_CURRENT_VERSION) {
hiddenColumns.add('scheduler_score')
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION)
}
} else {
DEFAULT_HIDDEN_COLUMNS.forEach(key => {
hiddenColumns.add(key)
})
localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION)
}
} catch (e) {
console.error('Failed to load saved columns:', e)
DEFAULT_HIDDEN_COLUMNS.forEach(key => {
hiddenColumns.add(key)
})
}
}
const saveColumnsToStorage = () => {
try {
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION)
} catch (e) {
console.error('Failed to save columns:', e)
}
}
const loadSavedAutoRefresh = () => {
try {
const saved = localStorage.getItem(AUTO_REFRESH_STORAGE_KEY)
if (!saved) return
const parsed = JSON.parse(saved) as { enabled?: boolean; interval_seconds?: number }
autoRefreshEnabled.value = parsed.enabled === true
const interval = Number(parsed.interval_seconds)
if (autoRefreshIntervals.includes(interval as any)) {
autoRefreshIntervalSeconds.value = interval as any
}
} catch (e) {
console.error('Failed to load saved auto refresh settings:', e)
}
}
const saveAutoRefreshToStorage = () => {
try {
localStorage.setItem(
AUTO_REFRESH_STORAGE_KEY,
JSON.stringify({
enabled: autoRefreshEnabled.value,
interval_seconds: autoRefreshIntervalSeconds.value
})
)
} catch (e) {
console.error('Failed to save auto refresh settings:', e)
}
}
if (typeof window !== 'undefined') {
loadSavedColumns()
loadSavedAutoRefresh()
}
const setAutoRefreshEnabled = (enabled: boolean) => {
autoRefreshEnabled.value = enabled
saveAutoRefreshToStorage()
if (enabled) {
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
resumeAutoRefresh()
} else {
pauseAutoRefresh()
autoRefreshCountdown.value = 0
}
}
const setAutoRefreshInterval = (seconds: (typeof autoRefreshIntervals)[number]) => {
autoRefreshIntervalSeconds.value = seconds
saveAutoRefreshToStorage()
if (autoRefreshEnabled.value) {
autoRefreshCountdown.value = seconds
}
}
const toggleColumn = (key: string) => {
const wasHidden = hiddenColumns.has(key)
if (hiddenColumns.has(key)) {
hiddenColumns.delete(key)
} else {
hiddenColumns.add(key)
}
saveColumnsToStorage()
if ((key === 'today_stats' || key === 'usage') && wasHidden) {
refreshTodayStatsBatch().catch((error) => {
console.error('Failed to load account today stats after showing column:', error)
})
}
if (key === 'scheduler_score') {
// The server only returns scheduler scores when this column is visible, so reload the current page immediately.
syncAccountListDerivedParams()
load().catch((error) => {
console.error('Failed to reload accounts after toggling scheduler score column:', error)
})
}
}
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
const shouldIncludeSchedulerScore = () => isColumnVisible('scheduler_score')
const syncAccountListDerivedParams = () => {
// Keep every load path, including auto-refresh and sorting, aligned with the current column visibility.
const requestParams = params as any
requestParams.include_scheduler_score = shouldIncludeSchedulerScore() ? '1' : '0'
}
const {
items: accounts,
loading,
params,
pagination,
load: baseLoad,
reload: baseReload,
debouncedReload: baseDebouncedReload,
handlePageChange: baseHandlePageChange,
handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list,
initialParams: {
platform: '',
type: '',
status: '',
privacy_mode: '',
group: '',
search: '',
include_scheduler_score: shouldIncludeSchedulerScore() ? '1' : '0',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}
})
const {
selectedIds: selIds,
allVisibleSelected,
isSelected,
setSelectedIds,
select,
deselect,
toggle: toggleSel,
clear: clearSelection,
removeMany: removeSelectedAccounts,
toggleVisible,
selectVisible: selectPage,
batchUpdate
} = useTableSelection<Account>({
rows: accounts,
getId: (account) => account.id
})
const swipeVirtualContext: SwipeSelectVirtualContext = {
getVirtualizer: () => dataTableRef.value?.virtualizer ?? null,
getSortedData: () => dataTableRef.value?.sortedData ?? accounts.value,
getRowId: (row: any) => row.id,
}
useSwipeSelect(accountTableRef, {
isSelected,
select,
deselect,
batchUpdate
}, swipeVirtualContext)
const resetAutoRefreshCache = () => {
autoRefreshETag.value = null
}
const isFirstLoad = ref(true)
const load = async () => {
const requestParams = params as any
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = false
if (isFirstLoad.value) {
requestParams.lite = '1'
}
await baseLoad()
if (isFirstLoad.value) {
isFirstLoad.value = false
delete requestParams.lite
}
await refreshTodayStatsBatch()
}
const reload = async () => {
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = false
await baseReload()
await refreshTodayStatsBatch()
}
const debouncedReload = () => {
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
baseDebouncedReload()
}
const handlePageChange = (page: number) => {
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
baseHandlePageChange(page)
}
const handlePageSizeChange = (size: number) => {
syncAccountListDerivedParams()
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
baseHandlePageSizeChange(size)
}
const handleSort = (key: string, order: AccountSortOrder) => {
sortState.sort_by = key
sortState.sort_order = order
const requestParams = params as any
requestParams.sort_by = key
requestParams.sort_order = order
syncAccountListDerivedParams()
pagination.page = 1
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
load()
}
watch(loading, (isLoading, wasLoading) => {
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false
refreshTodayStatsBatch().catch((error) => {
console.error('Failed to refresh account today stats after table load:', error)
})
}
})
const isAnyModalOpen = computed(() => {
return (
showCreate.value ||
showEdit.value ||
showSync.value ||
showImportData.value ||
showExportDataDialog.value ||
showBulkEdit.value ||
showTempUnsched.value ||
showDeleteDialog.value ||
showReAuth.value ||
showTest.value ||
showStats.value ||
showSchedulePanel.value ||
showErrorPassthrough.value ||
showTLSFingerprintProfiles.value
)
})
const enterAutoRefreshSilentWindow = () => {
autoRefreshSilentUntil.value = Date.now() + AUTO_REFRESH_SILENT_WINDOW_MS
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
}
const inAutoRefreshSilentWindow = () => {
return Date.now() < autoRefreshSilentUntil.value
}
const shouldReplaceAutoRefreshRow = (current: Account, next: Account) => {
return (
current.updated_at !== next.updated_at ||
current.current_concurrency !== next.current_concurrency ||
current.current_window_cost !== next.current_window_cost ||
current.active_sessions !== next.active_sessions ||
current.schedulable !== next.schedulable ||
current.status !== next.status ||
current.rate_limit_reset_at !== next.rate_limit_reset_at ||
current.overload_until !== next.overload_until ||
current.temp_unschedulable_until !== next.temp_unschedulable_until ||
buildOpenAIUsageRefreshKey(current) !== buildOpenAIUsageRefreshKey(next)
)
}
const syncAccountRefs = (nextAccount: Account) => {
if (edAcc.value?.id === nextAccount.id) edAcc.value = nextAccount
if (reAuthAcc.value?.id === nextAccount.id) reAuthAcc.value = nextAccount
if (tempUnschedAcc.value?.id === nextAccount.id) tempUnschedAcc.value = nextAccount
if (deletingAcc.value?.id === nextAccount.id) deletingAcc.value = nextAccount
if (menu.acc?.id === nextAccount.id) menu.acc = nextAccount
}
const mergeAccountsIncrementally = (nextRows: Account[]) => {
const currentRows = accounts.value
const currentByID = new Map(currentRows.map(row => [row.id, row]))
let changed = nextRows.length !== currentRows.length
const mergedRows = nextRows.map((nextRow) => {
const currentRow = currentByID.get(nextRow.id)
if (!currentRow) {
changed = true
return nextRow
}
if (shouldReplaceAutoRefreshRow(currentRow, nextRow)) {
changed = true
syncAccountRefs(nextRow)
return nextRow
}
return currentRow
})
if (!changed) {
for (let i = 0; i < mergedRows.length; i += 1) {
if (mergedRows[i].id !== currentRows[i]?.id) {
changed = true
break
}
}
}
if (changed) {
accounts.value = mergedRows
}
}
const refreshAccountsIncrementally = async () => {
if (autoRefreshFetching.value) return
syncAccountListDerivedParams()
autoRefreshFetching.value = true
try {
const result = await adminAPI.accounts.listWithEtag(
pagination.page,
pagination.page_size,
toRaw(params) as {
platform?: string
type?: string
status?: string
privacy_mode?: string
group?: string
search?: string
sort_by?: string
sort_order?: AccountSortOrder
},
{ etag: autoRefreshETag.value }
)
if (result.etag) {
autoRefreshETag.value = result.etag
}
if (!result.notModified && result.data) {
pagination.total = result.data.total || 0
pagination.pages = result.data.pages || 0
mergeAccountsIncrementally(result.data.items || [])
hasPendingListSync.value = false
}
await refreshTodayStatsBatch()
} catch (error) {
console.error('Auto refresh failed:', error)
} finally {
autoRefreshFetching.value = false
}
}
const handleManualRefresh = async () => {
await load()
// Force usage cells to refetch /usage on explicit user refresh.
usageManualRefreshToken.value += 1
}
const closeAccountToolsDropdown = () => {
showAccountToolsDropdown.value = false
}
const openSyncFromCrs = () => {
closeAccountToolsDropdown()
showSync.value = true
}
const openImportData = () => {
closeAccountToolsDropdown()
showImportData.value = true
}
const openExportDataDialogFromMenu = () => {
closeAccountToolsDropdown()
openExportDataDialog()
}
const openErrorPassthrough = () => {
closeAccountToolsDropdown()
showErrorPassthrough.value = true
}
const openTLSFingerprintProfiles = () => {
closeAccountToolsDropdown()
showTLSFingerprintProfiles.value = true
}
const syncPendingListChanges = async () => {
hasPendingListSync.value = false
await load()
// Keep behavior consistent with manual refresh.
usageManualRefreshToken.value += 1
}
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
async () => {
if (!autoRefreshEnabled.value) return
if (document.hidden) return
if (loading.value || autoRefreshFetching.value) return
if (isAnyModalOpen.value) return
if (menu.show || showAccountToolsDropdown.value || showAutoRefreshDropdown.value) return
if (inAutoRefreshSilentWindow()) {
autoRefreshCountdown.value = Math.max(
0,
Math.ceil((autoRefreshSilentUntil.value - Date.now()) / 1000)
)
return
}
if (autoRefreshCountdown.value <= 0) {
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
await refreshAccountsIncrementally()
return
}
autoRefreshCountdown.value -= 1
},
1000,
{ immediate: false }
)
// Fresh billing/quota snapshots are authoritative. Imported credential tiers
// can be stale, so they remain fallbacks together with legacy plan_type fields.
function getAccountPlanType(row: any): string | undefined {
if (!row) return undefined
if (row.platform === 'grok') {
const extra = (row.extra || {}) as Record<string, any>
const billing = extra.grok_billing_snapshot as Record<string, any> | undefined
const quota = extra.grok_quota_snapshot as Record<string, any> | undefined
return (
billing?.plan ||
quota?.subscription_tier ||
row.credentials?.subscription_tier ||
extra.subscription_tier ||
row.credentials?.plan_type ||
row.parent_plan_type ||
undefined
)
}
return row.credentials?.plan_type || row.parent_plan_type || undefined
}
function getOpenAIAuthMode(row: any): string | undefined {
if (!row || row.platform !== 'openai' || row.type !== 'oauth') return undefined
const authMode = row.credentials?.auth_mode
return typeof authMode === 'string' && authMode.trim() ? authMode : undefined
}
// Antigravity 订阅等级辅助函数
function getAntigravityTierFromRow(row: any): string | null {
if (row.platform !== 'antigravity') return null
const extra = row.extra as Record<string, unknown> | undefined
if (!extra) return null
const lca = extra.load_code_assist as Record<string, unknown> | undefined
if (!lca) return null
const paid = lca.paidTier as Record<string, unknown> | undefined
if (paid && typeof paid.id === 'string') return paid.id
const current = lca.currentTier as Record<string, unknown> | undefined
if (current && typeof current.id === 'string') return current.id
return null
}
function getAntigravityTierLabel(row: any): string | null {
const tier = getAntigravityTierFromRow(row)
switch (tier) {
case 'free-tier': return t('admin.accounts.tier.free')
case 'g1-pro-tier': return t('admin.accounts.tier.pro')
case 'g1-ultra-tier': return t('admin.accounts.tier.ultra')
default: return null
}
}
// 账号显示邮箱:优先账号自身(extra/credentials),影子账号回退母账号 parent_email。
// 供名称单元格 v-if/标题/文本三处共用,避免同一回退链在模板里重复三次。
function accountDisplayEmail(row: any): string {
return row.extra?.email_address || row.extra?.email || row.credentials?.email || row.parent_email || ''
}
type OpenAICompactBadgeState = 'active' | 'blocked' | 'auto'
function getOpenAICompactState(row: any): OpenAICompactBadgeState | null {
if (row.platform !== 'openai' || (row.type !== 'oauth' && row.type !== 'apikey')) return null
const extra = row.extra as Record<string, unknown> | undefined
const mode = typeof extra?.openai_compact_mode === 'string' ? extra.openai_compact_mode : 'auto'
if (mode === 'force_on') return 'active'
if (mode === 'force_off') return 'blocked'
if (typeof extra?.openai_compact_supported === 'boolean') {
return extra.openai_compact_supported ? 'active' : 'blocked'
}
return 'auto'
}
function getOpenAICompactMeta(row: any): { label: string; className: string; dotClass: string } | null {
const state = getOpenAICompactState(row)
if (!state) return null
switch (state) {
case 'active':
return {
label: t('admin.accounts.openai.compactSupported'),
className: 'text-emerald-600 dark:text-emerald-300',
dotClass: 'bg-emerald-500 shadow-[0_0_0_2px_rgba(16,185,129,0.14)]'
}
case 'blocked':
return {
label: t('admin.accounts.openai.compactUnsupported'),
className: 'text-rose-600 dark:text-rose-300',
dotClass: 'bg-rose-500 shadow-[0_0_0_2px_rgba(244,63,94,0.14)]'
}
case 'auto':
return {
label: t('admin.accounts.openai.compactAuto'),
className: 'text-slate-500 dark:text-slate-400',
dotClass: 'bg-slate-300 dark:bg-slate-500'
}
}
}
function getOpenAICompactTitle(row: any): string {
const extra = row.extra as Record<string, unknown> | undefined
const checkedAt = typeof extra?.openai_compact_checked_at === 'string' ? extra.openai_compact_checked_at : ''
const label = getOpenAICompactMeta(row)?.label || ''
if (!checkedAt) return label
return `${label} | ${t('admin.accounts.openai.compactLastChecked')}: ${formatDateTime(new Date(checkedAt))}`
}
function getAntigravityTierClass(row: any): string {
const tier = getAntigravityTierFromRow(row)
switch (tier) {
case 'free-tier': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
case 'g1-pro-tier': return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
case 'g1-ultra-tier': return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
default: return ''
}
}
// All available columns
const allColumns = computed(() => {
const c = [
{ key: 'select', label: '', sortable: false },
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
{ key: 'id', label: t('admin.accounts.columns.id'), sortable: true },
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
{ key: 'capacity', label: t('admin.accounts.columns.capacity'), sortable: false },
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false }
]
if (!authStore.isSimpleMode) {
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
}
c.push(
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'scheduler_score', label: t('admin.accounts.columns.schedulerScore'), sortable: false },
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'created_at', label: t('admin.accounts.columns.createdAt'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
)
return c
})
// Columns that can be toggled (exclude select, name, and actions)
const toggleableColumns = computed(() =>
allColumns.value.filter(col => col.key !== 'select' && col.key !== 'name' && col.key !== 'actions')
)
// Filtered columns based on visibility
const cols = computed(() =>
allColumns.value.filter(col =>
col.key === 'select' || col.key === 'name' || col.key === 'actions' || !hiddenColumns.has(col.key)
)
)
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
const openMenu = (a: Account, e: MouseEvent) => {
menu.acc = a
const target = e.currentTarget as HTMLElement
if (target) {
const rect = target.getBoundingClientRect()
const menuWidth = 200
const menuHeight = 240
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let left: number
let top: number
if (viewportWidth < 768) {
// 居中显示,水平位置
left = Math.max(padding, Math.min(
rect.left + rect.width / 2 - menuWidth / 2,
viewportWidth - menuWidth - padding
))
// 优先显示在按钮下方
top = rect.bottom + 4
// 如果下方空间不够,显示在上方
if (top + menuHeight > viewportHeight - padding) {
top = rect.top - menuHeight - 4
// 如果上方也不够,就贴在视口顶部
if (top < padding) {
top = padding
}
}
} else {
left = Math.max(padding, Math.min(
e.clientX - menuWidth,
viewportWidth - menuWidth - padding
))
top = e.clientY
if (top + menuHeight > viewportHeight - padding) {
top = viewportHeight - menuHeight - padding
}
}
menu.pos = { top, left }
} else {
menu.pos = { top: e.clientY, left: e.clientX - 200 }
}
menu.show = true
}
const toggleSelectAllVisible = (event: Event) => {
const target = event.target as HTMLInputElement
toggleVisible(target.checked)
}
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); clearSelection(); reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
const handleBulkResetStatus = async () => {
if (!confirm(t('common.confirm'))) return
try {
const result = await adminAPI.accounts.batchClearError(selIds.value)
if (result.failed > 0) {
appStore.showError(t('admin.accounts.bulkActions.partialSuccess', { success: result.success, failed: result.failed }))
} else {
appStore.showSuccess(t('admin.accounts.bulkActions.resetStatusSuccess', { count: result.success }))
clearSelection()
}
reload()
} catch (error) {
console.error('Failed to bulk reset status:', error)
appStore.showError(String(error))
}
}
const handleBulkRefreshToken = async () => {
if (!confirm(t('common.confirm'))) return
try {
const result = await adminAPI.accounts.batchRefresh(selIds.value)
if (result.failed > 0) {
appStore.showError(t('admin.accounts.bulkActions.partialSuccess', { success: result.success, failed: result.failed }))
} else {
appStore.showSuccess(t('admin.accounts.bulkActions.refreshTokenSuccess', { count: result.success }))
clearSelection()
}
reload()
} catch (error) {
console.error('Failed to bulk refresh token:', error)
appStore.showError(String(error))
}
}
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
if (accountIds.length === 0) return
const idSet = new Set(accountIds)
accounts.value = accounts.value.map((account) => (idSet.has(account.id) ? { ...account, schedulable } : account))
}
const normalizeBulkSchedulableResult = (
result: {
success?: number
failed?: number
success_ids?: number[]
failed_ids?: number[]
results?: Array<{ account_id: number; success: boolean }>
},
accountIds: number[]
) => {
const responseSuccessIds = Array.isArray(result.success_ids) ? result.success_ids : []
const responseFailedIds = Array.isArray(result.failed_ids) ? result.failed_ids : []
if (responseSuccessIds.length > 0 || responseFailedIds.length > 0) {
return {
successIds: responseSuccessIds,
failedIds: responseFailedIds,
successCount: typeof result.success === 'number' ? result.success : responseSuccessIds.length,
failedCount: typeof result.failed === 'number' ? result.failed : responseFailedIds.length,
hasIds: true,
hasCounts: true
}
}
const results = Array.isArray(result.results) ? result.results : []
if (results.length > 0) {
const successIds = results.filter(item => item.success).map(item => item.account_id)
const failedIds = results.filter(item => !item.success).map(item => item.account_id)
return {
successIds,
failedIds,
successCount: typeof result.success === 'number' ? result.success : successIds.length,
failedCount: typeof result.failed === 'number' ? result.failed : failedIds.length,
hasIds: true,
hasCounts: true
}
}
const hasExplicitCounts = typeof result.success === 'number' || typeof result.failed === 'number'
const successCount = typeof result.success === 'number' ? result.success : 0
const failedCount = typeof result.failed === 'number' ? result.failed : 0
if (hasExplicitCounts && failedCount === 0 && successCount === accountIds.length && accountIds.length > 0) {
return {
successIds: accountIds,
failedIds: [],
successCount,
failedCount,
hasIds: true,
hasCounts: true
}
}
return {
successIds: [],
failedIds: [],
successCount,
failedCount,
hasIds: false,
hasCounts: hasExplicitCounts
}
}
const handleBulkToggleSchedulable = async (schedulable: boolean) => {
const accountIds = [...selIds.value]
try {
const result = await adminAPI.accounts.bulkUpdate(accountIds, { schedulable })
const { successIds, failedIds, successCount, failedCount, hasIds, hasCounts } = normalizeBulkSchedulableResult(result, accountIds)
if (!hasIds && !hasCounts) {
appStore.showError(t('admin.accounts.bulkSchedulableResultUnknown'))
setSelectedIds(accountIds)
load().catch((error) => {
console.error('Failed to refresh accounts:', error)
})
return
}
if (successIds.length > 0) {
updateSchedulableInList(successIds, schedulable)
}
if (successCount > 0 && failedCount === 0) {
const message = schedulable
? t('admin.accounts.bulkSchedulableEnabled', { count: successCount })
: t('admin.accounts.bulkSchedulableDisabled', { count: successCount })
appStore.showSuccess(message)
}
if (failedCount > 0) {
const message = hasCounts || hasIds
? t('admin.accounts.bulkSchedulablePartial', { success: successCount, failed: failedCount })
: t('admin.accounts.bulkSchedulableResultUnknown')
appStore.showError(message)
setSelectedIds(failedIds.length > 0 ? failedIds : accountIds)
} else {
if (hasIds) clearSelection()
else setSelectedIds(accountIds)
}
} catch (error) {
console.error('Failed to bulk toggle schedulable:', error)
appStore.showError(t('common.error'))
}
}
const buildBulkEditFilterSnapshot = () => {
const rawParams = toRaw(params) as Record<string, unknown>
const sortOrder: AccountSortOrder = rawParams.sort_order === 'desc' ? 'desc' : 'asc'
return {
platform: typeof rawParams.platform === 'string' ? rawParams.platform : '',
type: typeof rawParams.type === 'string' ? rawParams.type : '',
status: typeof rawParams.status === 'string' ? rawParams.status : '',
group: typeof rawParams.group === 'string' ? rawParams.group : '',
search: typeof rawParams.search === 'string' ? rawParams.search : '',
privacy_mode: typeof rawParams.privacy_mode === 'string' ? rawParams.privacy_mode : '',
sort_by: typeof rawParams.sort_by === 'string' ? rawParams.sort_by : '',
sort_order: sortOrder
}
}
const collectSelectionMetadata = (rows: Account[]) => {
const selectedPlatforms = Array.from(new Set(rows.map(account => account.platform)))
const selectedTypes = Array.from(new Set(rows.map(account => account.type)))
return { selectedPlatforms, selectedTypes }
}
const openBulkEditSelected = () => {
bulkEditTarget.value = {
mode: 'selected',
accountIds: [...selIds.value],
selectedPlatforms: [...selPlatforms.value],
selectedTypes: [...selTypes.value]
}
showBulkEdit.value = true
}
const openBulkEditFiltered = async () => {
const filters = buildBulkEditFilterSnapshot()
const preview = await adminAPI.accounts.list(1, 100, filters)
const { selectedPlatforms, selectedTypes } = collectSelectionMetadata(preview.items)
bulkEditTarget.value = {
mode: 'filtered',
filters,
previewCount: preview.total,
selectedPlatforms,
selectedTypes
}
showBulkEdit.value = true
}
const handleBulkUpdated = () => {
showBulkEdit.value = false
bulkEditTarget.value = null
clearSelection()
reload()
}
const handleDataImported = () => { showImportData.value = false; reload() }
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
const buildAccountQueryFilters = () => ({
platform: params.platform || '',
type: params.type || '',
status: params.status || '',
group: params.group || '',
privacy_mode: params.privacy_mode || '',
search: params.search || '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const accountMatchesCurrentFilters = (account: Account) => {
const filters = buildAccountQueryFilters()
if (filters.platform && account.platform !== filters.platform) return false
if (filters.type && account.type !== filters.type) return false
if (filters.status) {
const now = Date.now()
const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN
const isRateLimited = Number.isFinite(rateLimitResetAt) && rateLimitResetAt > now
const tempUnschedUntil = account.temp_unschedulable_until ? new Date(account.temp_unschedulable_until).getTime() : Number.NaN
const isTempUnschedulable = Number.isFinite(tempUnschedUntil) && tempUnschedUntil > now
if (filters.status === 'active') {
if (account.status !== 'active' || isRateLimited || isTempUnschedulable || !account.schedulable) return false
} else if (filters.status === 'rate_limited') {
if (account.status !== 'active' || !isRateLimited || isTempUnschedulable) return false
} else if (filters.status === 'temp_unschedulable') {
if (account.status !== 'active' || !isTempUnschedulable) return false
} else if (filters.status === 'unschedulable') {
if (account.status !== 'active' || account.schedulable || isRateLimited || isTempUnschedulable) return false
} else if (account.status !== filters.status) {
return false
}
}
if (filters.group) {
const groupIds = account.group_ids ?? account.groups?.map((group) => group.id) ?? []
if (filters.group === ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE) {
if (groupIds.length > 0) return false
} else if (!groupIds.includes(Number(filters.group))) {
return false
}
}
const privacyMode = typeof account.extra?.privacy_mode === 'string' ? account.extra.privacy_mode : ''
if (filters.privacy_mode) {
if (filters.privacy_mode === ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE) {
if (privacyMode.trim() !== '') return false
} else if (privacyMode !== filters.privacy_mode) {
return false
}
}
const search = String(filters.search || '').trim().toLowerCase()
if (search && !account.name.toLowerCase().includes(search)) return false
return true
}
const mergeRuntimeFields = (oldAccount: Account, updatedAccount: Account): Account => ({
...updatedAccount,
current_concurrency: updatedAccount.current_concurrency ?? oldAccount.current_concurrency,
current_window_cost: updatedAccount.current_window_cost ?? oldAccount.current_window_cost,
active_sessions: updatedAccount.active_sessions ?? oldAccount.active_sessions
})
const syncPaginationAfterLocalRemoval = () => {
const nextTotal = Math.max(0, pagination.total - 1)
pagination.total = nextTotal
pagination.pages = nextTotal > 0 ? Math.ceil(nextTotal / pagination.page_size) : 0
const maxPage = Math.max(1, pagination.pages || 1)
if (pagination.page > maxPage) {
pagination.page = maxPage
}
// 行被本地移除后不立刻全量补页,改为提示用户手动同步。
hasPendingListSync.value = nextTotal > 0
}
const patchAccountInList = (updatedAccount: Account) => {
const index = accounts.value.findIndex(account => account.id === updatedAccount.id)
if (index === -1) return
const mergedAccount = mergeRuntimeFields(accounts.value[index], updatedAccount)
if (!accountMatchesCurrentFilters(mergedAccount)) {
accounts.value = accounts.value.filter(account => account.id !== mergedAccount.id)
syncPaginationAfterLocalRemoval()
removeSelectedAccounts([mergedAccount.id])
if (menu.acc?.id === mergedAccount.id) {
menu.show = false
menu.acc = null
}
return
}
const nextAccounts = [...accounts.value]
nextAccounts[index] = mergedAccount
accounts.value = nextAccounts
syncAccountRefs(mergedAccount)
}
const handleAccountUpdated = (updatedAccount: Account) => {
patchAccountInList(updatedAccount)
enterAutoRefreshSilentWindow()
}
const formatExportTimestamp = () => {
const now = new Date()
const pad2 = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`
}
const openExportDataDialog = () => {
includeProxyOnExport.value = true
showExportDataDialog.value = true
}
const handleExportData = async () => {
if (exportingData.value) return
exportingData.value = true
try {
const dataPayload = await adminAPI.accounts.exportData(
selIds.value.length > 0
? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
: {
includeProxies: includeProxyOnExport.value,
filters: buildAccountQueryFilters()
}
)
const timestamp = formatExportTimestamp()
const filename = `sub2api-account-${timestamp}.json`
const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
// spark 影子账号被后端排除出备份(其凭据透传母账号、调度配置不可经凭据型导入重建);
// 跳过非零时明确提示用户,避免「下载成功但少了账号」的静默丢失。
if (dataPayload.skipped_shadows && dataPayload.skipped_shadows > 0) {
appStore.showWarning(t('admin.accounts.dataExportedSkippedShadows', { count: dataPayload.skipped_shadows }))
} else {
appStore.showSuccess(t('admin.accounts.dataExported'))
}
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.dataExportFailed'))
} finally {
exportingData.value = false
showExportDataDialog.value = false
}
}
const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = null }
const handleTest = (a: Account) => { testingAcc.value = a; showTest.value = true }
const handleViewStats = (a: Account) => { statsAcc.value = a; showStats.value = true }
const handleSchedule = async (a: Account) => {
scheduleAcc.value = a
scheduleModelOptions.value = []
showSchedulePanel.value = true
try {
const models = await adminAPI.accounts.getAvailableModels(a.id)
scheduleModelOptions.value = models.map((m: ClaudeModel) => ({ value: m.id, label: m.display_name || m.id }))
} catch {
scheduleModelOptions.value = []
}
}
const closeSchedulePanel = () => { showSchedulePanel.value = false; scheduleAcc.value = null; scheduleModelOptions.value = [] }
const handleReAuth = (a: Account) => { reAuthAcc.value = a; showReAuth.value = true }
const duplicatingAccountIDs = new Set<number>()
const handleDuplicateAccount = async (a: Account) => {
if (duplicatingAccountIDs.has(a.id)) return
duplicatingAccountIDs.add(a.id)
try {
const duplicate = await adminAPI.accounts.duplicate(a.id)
appStore.showSuccess(t('admin.accounts.duplicateSuccess', { name: duplicate.name }))
reload()
} catch (error: any) {
console.error('Failed to duplicate account:', error)
appStore.showError(error?.message || t('admin.accounts.duplicateFailed'))
} finally {
duplicatingAccountIDs.delete(a.id)
}
}
const handleRefresh = async (a: Account) => {
try {
const updated = await adminAPI.accounts.refreshCredentials(a.id)
patchAccountInList(updated)
enterAutoRefreshSilentWindow()
} catch (error) {
console.error('Failed to refresh credentials:', error)
}
}
const handleRecoverState = async (a: Account) => {
try {
const updated = await adminAPI.accounts.recoverState(a.id)
patchAccountInList(updated)
enterAutoRefreshSilentWindow()
appStore.showSuccess(t('admin.accounts.recoverStateSuccess'))
} catch (error: any) {
console.error('Failed to recover account state:', error)
appStore.showError(error?.message || t('admin.accounts.recoverStateFailed'))
}
}
const handleResetQuota = async (a: Account) => {
try {
const updated = await adminAPI.accounts.resetAccountQuota(a.id)
patchAccountInList(updated)
enterAutoRefreshSilentWindow()
appStore.showSuccess(t('common.success'))
} catch (error) {
console.error('Failed to reset quota:', error)
}
}
const privacyResultMessageKey = (account: Account): { type: 'success' | 'error'; key: string } => {
const mode = typeof account.extra?.privacy_mode === 'string' ? account.extra.privacy_mode : ''
if (account.platform === 'openai') {
switch (mode) {
case 'training_off':
return { type: 'success', key: 'admin.accounts.privacyTrainingOff' }
case 'training_set_cf_blocked':
return { type: 'error', key: 'admin.accounts.privacyCfBlocked' }
default:
return { type: 'error', key: 'admin.accounts.privacyFailed' }
}
}
if (account.platform === 'antigravity') {
if (mode === 'privacy_set') {
return { type: 'success', key: 'admin.accounts.privacyAntigravitySet' }
}
return { type: 'error', key: 'admin.accounts.privacyAntigravityFailed' }
}
return { type: 'error', key: 'admin.accounts.privacyFailed' }
}
const handleSetPrivacy = async (a: Account) => {
try {
const updated = await adminAPI.accounts.setPrivacy(a.id)
patchAccountInList(updated)
enterAutoRefreshSilentWindow()
const result = privacyResultMessageKey(updated)
if (result.type === 'success') {
appStore.showSuccess(t(result.key))
} else {
appStore.showError(t(result.key))
}
} catch (error: any) {
console.error('Failed to set privacy:', error)
appStore.showError(error?.response?.data?.message || t('admin.accounts.privacyFailed'))
}
}
const onRevertFallback = async (a: Account) => {
try {
await adminAPI.accounts.revertProxyFallback(a.id)
appStore.showSuccess(t('admin.accounts.revertProxySuccess'))
reload()
} catch (error: any) {
console.error('Failed to revert proxy fallback:', error)
appStore.showError(error?.response?.data?.message || t('admin.accounts.revertProxyFailed'))
}
}
const handleCreateSparkShadow = (a: Account) => {
creatingShadowAcc.value = a
showCreateShadowDialog.value = true
}
const confirmCreateSparkShadow = async () => {
const a = creatingShadowAcc.value
if (!a) return
try {
await adminAPI.accounts.createSparkShadow(a.id, { name: `${a.name} (Spark)` })
showCreateShadowDialog.value = false
creatingShadowAcc.value = null
appStore.showSuccess(t('admin.accounts.createSparkShadowSuccess'))
reload()
} catch (error: any) {
console.error('Failed to create spark shadow:', error)
appStore.showError(error?.response?.data?.message || t('admin.accounts.createSparkShadowFailed'))
}
}
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
const handleToggleSchedulable = async (a: Account) => {
const nextSchedulable = !a.schedulable
togglingSchedulable.value = a.id
try {
const updated = await adminAPI.accounts.setSchedulable(a.id, nextSchedulable)
updateSchedulableInList([a.id], updated?.schedulable ?? nextSchedulable)
enterAutoRefreshSilentWindow()
} catch (error) {
console.error('Failed to toggle schedulable:', error)
appStore.showError(t('admin.accounts.failedToToggleSchedulable'))
} finally {
togglingSchedulable.value = null
}
}
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
const handleTempUnschedReset = async (updated: Account) => {
showTempUnsched.value = false
tempUnschedAcc.value = null
patchAccountInList(updated)
enterAutoRefreshSilentWindow()
}
const formatExpiresAt = (value: number | null) => {
if (!value) return '-'
return formatDateTime(
new Date(value * 1000),
{
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
},
'sv-SE'
)
}
const isExpired = (value: number | null) => {
if (!value) return false
return value * 1000 <= Date.now()
}
// 所绑定代理的有效期(逻辑同 /admin/proxies,见 utils/proxyExpiry)
const proxyExpiryBadge = (p: AccountProxy): string => proxyExpiryBadgeClass(p.expires_at, p.status)
const proxyExpiryText = (p: AccountProxy): string => {
const { key, params } = proxyExpiryLabelKey(p.expires_at, p.status)
return params ? t(key, params) : t(key)
}
// 滚动时关闭操作菜单(不关闭列设置下拉菜单)
const handleScroll = () => {
menu.show = false
}
// 点击外部关闭顶部下拉菜单
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (accountToolsDropdownRef.value && !accountToolsDropdownRef.value.contains(target)) {
showAccountToolsDropdown.value = false
}
if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) {
showAutoRefreshDropdown.value = false
}
}
onMounted(async () => {
load()
try {
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
proxies.value = p
groups.value = g
} catch (error) {
console.error('Failed to load proxies/groups:', error)
}
window.addEventListener('scroll', handleScroll, true)
document.addEventListener('click', handleClickOutside)
if (autoRefreshEnabled.value) {
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
resumeAutoRefresh()
} else {
pauseAutoRefresh()
}
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll, true)
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.account-tools-menu-item {
@apply flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700;
}
.account-tools-menu-icon {
@apply inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md;
}
</style>