mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-04-25 17:58:38 +00:00
Show a semi-transparent blue rectangle overlay while dragging to select rows, matching the project's primary color theme with dark mode support. The box spans the full table width from drag start to current mouse position. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1237 lines
51 KiB
Vue
1237 lines
51 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"
|
|
@sync="showSync = true"
|
|
@create="showCreate = true"
|
|
>
|
|
<template #after>
|
|
<!-- Auto Refresh Dropdown -->
|
|
<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>
|
|
|
|
<!-- Error Passthrough Rules -->
|
|
<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>
|
|
|
|
<!-- Column Settings Dropdown -->
|
|
<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>
|
|
<!-- Dropdown menu -->
|
|
<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" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
|
<div ref="accountTableRef">
|
|
<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 }">
|
|
<PlatformTypeBadge :platform="row.platform" :type="row.type" />
|
|
</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" />
|
|
</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" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" @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 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 { formatDateTime, formatRelativeTime } from '@/utils/format'
|
|
import type { Account, AccountPlatform, AccountType, Proxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
|
|
|
|
const { t } = useI18n()
|
|
const appStore = useAppStore()
|
|
const authStore = useAuthStore()
|
|
|
|
const proxies = ref<Proxy[]>([])
|
|
const groups = ref<AdminGroup[]>([])
|
|
const selIds = ref<number[]>([])
|
|
const accountTableRef = ref<HTMLElement | null>(null)
|
|
useSwipeSelect(accountTableRef, {
|
|
isSelected: (id) => selIds.value.includes(id),
|
|
select: (id) => { if (!selIds.value.includes(id)) selIds.value.push(id) },
|
|
deselect: (id) => { selIds.value = selIds.value.filter(x => x !== id) }
|
|
})
|
|
const selPlatforms = computed<AccountPlatform[]>(() => {
|
|
const platforms = new Set(
|
|
accounts.value
|
|
.filter(a => selIds.value.includes(a.id))
|
|
.map(a => a.platform)
|
|
)
|
|
return [...platforms]
|
|
})
|
|
const selTypes = computed<AccountType[]>(() => {
|
|
const types = new Set(
|
|
accounts.value
|
|
.filter(a => selIds.value.includes(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)
|
|
|
|
// Column settings
|
|
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'
|
|
|
|
// Sorting settings
|
|
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
|
|
|
|
// 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 buildDefaultTodayStats = (): WindowStats => ({
|
|
requests: 0,
|
|
tokens: 0,
|
|
cost: 0,
|
|
standard_cost: 0,
|
|
user_cost: 0
|
|
})
|
|
|
|
const refreshTodayStatsBatch = async () => {
|
|
if (hiddenColumns.has('today_stats')) {
|
|
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' && 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 resetAutoRefreshCache = () => {
|
|
autoRefreshETag.value = null
|
|
}
|
|
|
|
const isFirstLoad = ref(true)
|
|
|
|
const load = async () => {
|
|
hasPendingListSync.value = false
|
|
resetAutoRefreshCache()
|
|
pendingTodayStatsRefresh.value = false
|
|
if (isFirstLoad.value) {
|
|
;(params as any).lite = '1'
|
|
}
|
|
await baseLoad()
|
|
if (isFirstLoad.value) {
|
|
isFirstLoad.value = false
|
|
delete (params as any).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
|
|
)
|
|
}
|
|
|
|
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
|
|
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()
|
|
}
|
|
|
|
const syncPendingListChanges = async () => {
|
|
hasPendingListSync.value = false
|
|
await load()
|
|
}
|
|
|
|
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 }
|
|
)
|
|
|
|
// All available columns
|
|
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
|
|
})
|
|
|
|
// 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, top
|
|
|
|
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 toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
|
|
const allVisibleSelected = computed(() => {
|
|
if (accounts.value.length === 0) return false
|
|
return accounts.value.every(account => selIds.value.includes(account.id))
|
|
})
|
|
const toggleSelectAllVisible = (event: Event) => {
|
|
const target = event.target as HTMLInputElement
|
|
if (target.checked) {
|
|
const next = new Set(selIds.value)
|
|
accounts.value.forEach(account => next.add(account.id))
|
|
selIds.value = Array.from(next)
|
|
return
|
|
}
|
|
const visibleIds = new Set(accounts.value.map(account => account.id))
|
|
selIds.value = selIds.value.filter(id => !visibleIds.has(id))
|
|
}
|
|
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
|
|
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', 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'))
|
|
selIds.value = 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)
|
|
selIds.value = failedIds.length > 0 ? failedIds : accountIds
|
|
} else {
|
|
selIds.value = hasIds ? [] : accountIds
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to bulk toggle schedulable:', error)
|
|
appStore.showError(t('common.error'))
|
|
}
|
|
}
|
|
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; 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()
|
|
selIds.value = selIds.value.filter(id => id !== 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 handleResetStatus = async (a: Account) => {
|
|
try {
|
|
const updated = await adminAPI.accounts.clearError(a.id)
|
|
patchAccountInList(updated)
|
|
enterAutoRefreshSilentWindow()
|
|
appStore.showSuccess(t('common.success'))
|
|
} catch (error) {
|
|
console.error('Failed to reset status:', error)
|
|
}
|
|
}
|
|
const handleClearRateLimit = async (a: Account) => {
|
|
try {
|
|
const updated = await adminAPI.accounts.clearRateLimit(a.id)
|
|
patchAccountInList(updated)
|
|
enterAutoRefreshSilentWindow()
|
|
appStore.showSuccess(t('common.success'))
|
|
} catch (error) {
|
|
console.error('Failed to clear rate limit:', error)
|
|
}
|
|
}
|
|
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 () => {
|
|
if(!tempUnschedAcc.value) return
|
|
try {
|
|
const updated = await adminAPI.accounts.clearError(tempUnschedAcc.value.id)
|
|
showTempUnsched.value = false
|
|
tempUnschedAcc.value = null
|
|
patchAccountInList(updated)
|
|
enterAutoRefreshSilentWindow()
|
|
} catch (error) {
|
|
console.error('Failed to reset temp unscheduled:', error)
|
|
}
|
|
}
|
|
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>
|