| <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" |
| @sync="showSync = true" |
| @create="showCreate = true" |
| > |
| <template #after> |
| |
| <div class="relative" ref="autoRefreshDropdownRef"> |
| <button |
| @click=" |
| showAutoRefreshDropdown = !showAutoRefreshDropdown; |
| showColumnDropdown = 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> |
| |
| |
| <button |
| @click="showErrorPassthrough = true" |
| class="btn btn-secondary" |
| :title="t('admin.errorPassthrough.title')" |
| > |
| <Icon name="shield" size="md" class="mr-1.5" /> |
| <span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span> |
| </button> |
| |
| |
| <div class="relative" ref="columnDropdownRef"> |
| <button |
| @click=" |
| showColumnDropdown = !showColumnDropdown; |
| showAutoRefreshDropdown = false |
| " |
| class="btn btn-secondary px-2 md:px-3" |
| :title="t('admin.users.columnSettings')" |
| > |
| <svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" /> |
| </svg> |
| <span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span> |
| </button> |
| |
| <div |
| v-if="showColumnDropdown" |
| class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800" |
| > |
| <div class="max-h-80 overflow-y-auto p-2"> |
| <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 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700" |
| > |
| <span>{{ col.label }}</span> |
| <Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" /> |
| </button> |
| </div> |
| </div> |
| </div> |
| </template> |
| <template #beforeCreate> |
| <button @click="showImportData = true" class="btn btn-secondary"> |
| {{ t('admin.accounts.dataImport') }} |
| </button> |
| <button @click="openExportDataDialog" class="btn btn-secondary"> |
| {{ selIds.length ? t('admin.accounts.dataExportSelected') : t('admin.accounts.dataExport') }} |
| </button> |
| </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="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" /> |
| <div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden"> |
| <DataTable |
| :columns="cols" |
| :data="accounts" |
| :loading="loading" |
| row-key="id" |
| default-sort-key="name" |
| default-sort-order="asc" |
| :sort-storage-key="ACCOUNT_SORT_STORAGE_KEY" |
| > |
| <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="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" /> |
| </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="row.extra?.email_address" |
| class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]" |
| :title="row.extra.email_address" |
| > |
| {{ row.extra.email_address }} |
| </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 flex-wrap items-center gap-1"> |
| <PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" :privacy-mode="row.extra?.privacy_mode" /> |
| <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> |
| </template> |
| <template #cell-capacity="{ row }"> |
| <AccountCapacityCell :account="row" /> |
| </template> |
| <template #cell-status="{ row }"> |
| <AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" /> |
| </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 #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 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> |
| </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 #cell-last_used_at="{ value }"> |
| <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(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" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" /> |
| <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" :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="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" /> |
| </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 } 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 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 { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' |
| import { formatDateTime, formatRelativeTime } from '@/utils/format' |
| import type { Account, AccountPlatform, 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 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 showTempUnsched = ref(false) |
| const showDeleteDialog = ref(false) |
| const showReAuth = ref(false) |
| const showTest = ref(false) |
| const showStats = ref(false) |
| const showErrorPassthrough = ref(false) |
| const edAcc = ref<Account | null>(null) |
| const tempUnschedAcc = ref<Account | null>(null) |
| const deletingAcc = 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) |
| |
| |
| const showColumnDropdown = ref(false) |
| const columnDropdownRef = ref<HTMLElement | null>(null) |
| const hiddenColumns = reactive<Set<string>>(new Set()) |
| const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier'] |
| const HIDDEN_COLUMNS_KEY = 'account-hidden-columns' |
| |
| |
| const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort' |
| |
| |
| 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 () => { |
| |
| |
| |
| |
| 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 loadSavedColumns = () => { |
| try { |
| const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY) |
| if (saved) { |
| const parsed = JSON.parse(saved) as string[] |
| parsed.forEach(key => { |
| hiddenColumns.add(key) |
| }) |
| } else { |
| DEFAULT_HIDDEN_COLUMNS.forEach(key => { |
| hiddenColumns.add(key) |
| }) |
| } |
| } 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])) |
| } 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) |
| }) |
| } |
| } |
| |
| const isColumnVisible = (key: string) => !hiddenColumns.has(key) |
| |
| 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: '', group: '', search: '' } |
| }) |
| |
| const { |
| selectedIds: selIds, |
| allVisibleSelected, |
| isSelected, |
| setSelectedIds, |
| select, |
| deselect, |
| toggle: toggleSel, |
| clear: clearSelection, |
| removeMany: removeSelectedAccounts, |
| toggleVisible, |
| selectVisible: selectPage |
| } = useTableSelection<Account>({ |
| rows: accounts, |
| getId: (account) => account.id |
| }) |
| |
| useSwipeSelect(accountTableRef, { |
| isSelected, |
| select, |
| deselect |
| }) |
| |
| const resetAutoRefreshCache = () => { |
| autoRefreshETag.value = null |
| } |
| |
| const isFirstLoad = ref(true) |
| |
| const load = async () => { |
| const requestParams = params as any |
| 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 () => { |
| hasPendingListSync.value = false |
| resetAutoRefreshCache() |
| pendingTodayStatsRefresh.value = false |
| await baseReload() |
| await refreshTodayStatsBatch() |
| } |
| |
| const debouncedReload = () => { |
| hasPendingListSync.value = false |
| resetAutoRefreshCache() |
| pendingTodayStatsRefresh.value = true |
| baseDebouncedReload() |
| } |
| |
| const handlePageChange = (page: number) => { |
| hasPendingListSync.value = false |
| resetAutoRefreshCache() |
| pendingTodayStatsRefresh.value = true |
| baseHandlePageChange(page) |
| } |
| |
| const handlePageSizeChange = (size: number) => { |
| hasPendingListSync.value = false |
| resetAutoRefreshCache() |
| pendingTodayStatsRefresh.value = true |
| baseHandlePageSizeChange(size) |
| } |
| |
| 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 |
| ) |
| }) |
| |
| 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 |
| autoRefreshFetching.value = true |
| try { |
| const result = await adminAPI.accounts.listWithEtag( |
| pagination.page, |
| pagination.page_size, |
| toRaw(params) as { |
| platform?: string |
| type?: string |
| status?: string |
| group?: string |
| search?: string |
| |
| }, |
| { 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() |
| |
| usageManualRefreshToken.value += 1 |
| } |
| |
| const syncPendingListChanges = async () => { |
| hasPendingListSync.value = false |
| await load() |
| |
| 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) 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 } |
| ) |
| |
| |
| 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 |
| } |
| } |
| |
| 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 '' |
| } |
| } |
| |
| |
| const allColumns = computed(() => { |
| const c = [ |
| { key: 'select', label: '', sortable: false }, |
| { key: 'name', label: t('admin.accounts.columns.name'), 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: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true }, |
| { key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), 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 |
| }) |
| |
| |
| const toggleableColumns = computed(() => |
| allColumns.value.filter(col => col.key !== 'select' && col.key !== 'name' && col.key !== 'actions') |
| ) |
| |
| |
| 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 handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() } |
| const handleDataImported = () => { showImportData.value = false; reload() } |
| const accountMatchesCurrentFilters = (account: Account) => { |
| if (params.platform && account.platform !== params.platform) return false |
| if (params.type && account.type !== params.type) return false |
| if (params.status) { |
| if (params.status === 'rate_limited') { |
| if (!account.rate_limit_reset_at) return false |
| const resetAt = new Date(account.rate_limit_reset_at).getTime() |
| if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false |
| } else if (account.status !== params.status) { |
| return false |
| } |
| } |
| const search = String(params.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: { |
| platform: params.platform, |
| type: params.type, |
| status: params.status, |
| search: params.search |
| } |
| } |
| ) |
| 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) |
| 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 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 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() |
| } |
| |
| |
| const handleScroll = () => { |
| menu.show = false |
| } |
| |
| |
| const handleClickOutside = (event: MouseEvent) => { |
| const target = event.target as HTMLElement |
| if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) { |
| showColumnDropdown.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> |
| |