2025-12-18 13:50:39 +08:00
< template >
< AppLayout >
2025-12-27 10:50:25 +08:00
< TablePageLayout >
2026-01-05 01:00:00 +08:00
< template # filters >
< div class = "flex flex-wrap-reverse items-start justify-between gap-3" >
2026-01-04 21:46:23 -08:00
< AccountTableFilters
v -model:searchQuery = "params.search"
:filters = "params"
2026-01-06 19:20:05 +08:00
@ update : filters = "(newFilters) => Object.assign(params, newFilters)"
2026-01-09 17:58:21 +08:00
@change ="debouncedReload"
2026-01-04 21:46:23 -08:00
@ update :searchQuery = "debouncedReload"
/>
< AccountTableActions
:loading = "loading"
@refresh ="load"
@sync ="showSync = true"
@create ="showCreate = true"
2026-01-18 11:57:07 +08:00
>
2026-01-20 00:18:51 +08:00
< template # after >
2026-01-21 22:43:25 +08:00
<!-- 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 >
2026-01-18 11:57:07 +08:00
<!-- Column Settings Dropdown -->
< div class = "relative" ref = "columnDropdownRef" >
< button
2026-01-21 22:43:25 +08:00
@click ="
showColumnDropdown = !showColumnDropdown;
showAutoRefreshDropdown = false
"
2026-01-18 11:57:07 +08:00
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 >
2026-02-05 17:46:08 +08:00
< template # afterCreate >
< 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 >
2026-01-18 11:57:07 +08:00
</ AccountTableActions >
2026-01-05 01:00:00 +08:00
</ div >
</ template >
2025-12-27 10:50:25 +08:00
< template # table >
2026-01-09 19:26:32 +08:00
< AccountBulkActionsBar :selected-ids = "selIds" @delete ="handleBulkDelete" @edit ="showBulkEdit = true" @clear ="selIds = []" @ select -page =" selectPage " @ toggle -schedulable =" handleBulkToggleSchedulable " />
2026-01-21 22:43:25 +08:00
< 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"
>
2026-01-05 00:38:23 +08:00
< 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 >
2026-01-23 15:17:47 +08:00
< 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 >
2026-01-05 00:38:23 +08:00
</ template >
2026-01-05 14:07:33 +08:00
< 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 >
2026-01-05 00:38:23 +08:00
< template # cell -platform_type =" { row } " >
< PlatformTypeBadge :platform = "row.platform" :type = "row.type" />
</ template >
2026-01-16 23:36:52 +08:00
< template # cell -capacity =" { row } " >
< AccountCapacityCell :account = "row" />
2026-01-05 00:38:23 +08:00
</ 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 :account = "row" />
</ template >
< template # cell -groups =" { row } " >
2026-01-12 16:08:44 +08:00
< AccountGroupsCell :groups = "row.groups" :max-display = "4" />
2026-01-05 00:38:23 +08:00
</ template >
< template # cell -usage =" { row } " >
< AccountUsageCell :account = "row" />
</ template >
2026-01-18 11:57:07 +08:00
< 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 >
2026-01-15 15:14:44 +08:00
< 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 >
2026-01-05 00:38:23 +08:00
< 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 >
2026-01-07 16:59:35 +08:00
< 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 >
2026-01-05 00:38:23 +08:00
< 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 >
2025-12-18 13:50:39 +08:00
</ DataTable >
2025-12-27 10:50:25 +08:00
</ template >
2026-01-09 17:38:45 +08:00
< 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 >
2025-12-27 10:50:25 +08:00
</ TablePageLayout >
2026-01-04 22:29:19 +08:00
< CreateAccountModal :show = "showCreate" :proxies = "proxies" :groups = "groups" @close ="showCreate = false" @created ="reload" />
2026-01-04 22:17:27 +08:00
< EditAccountModal :show = "showEdit" :account = "edAcc" :proxies = "proxies" :groups = "groups" @close ="showEdit = false" @updated ="load" />
2026-01-05 00:38:23 +08:00
< ReAuthAccountModal :show = "showReAuth" :account = "reAuthAcc" @close ="closeReAuthModal" @reauthorized ="load" />
< AccountTestModal :show = "showTest" :account = "testingAcc" @close ="closeTestModal" />
< AccountStatsModal :show = "showStats" :account = "statsAcc" @close ="closeStatsModal" />
< AccountActionMenu :show = "menu.show" :account = "menu.acc" :position = "menu.pos" @close ="menu.show = false" @test ="handleTest" @stats ="handleViewStats" @reauth ="handleReAuth" @ refresh -token =" handleRefresh " @ reset -status =" handleResetStatus " @ clear -rate-limit =" handleClearRateLimit " />
2026-01-04 22:29:19 +08:00
< SyncFromCrsModal :show = "showSync" @close ="showSync = false" @synced ="reload" />
2026-02-05 17:46:08 +08:00
< ImportDataModal :show = "showImportData" @close ="showImportData = false" @imported ="handleDataImported" />
2026-01-04 22:17:27 +08:00
< BulkEditAccountModal :show = "showBulkEdit" :account-ids = "selIds" :proxies = "proxies" :groups = "groups" @close ="showBulkEdit = false" @updated ="handleBulkUpdated" />
2026-01-05 00:38:23 +08:00
< 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" />
2026-02-05 17:46:08 +08:00
< 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 >
2025-12-18 13:50:39 +08:00
</ AppLayout >
</ template >
< script setup lang = "ts" >
2026-01-15 15:14:44 +08:00
import { ref , reactive , computed , onMounted , onUnmounted } from 'vue'
2026-01-21 22:43:25 +08:00
import { useIntervalFn } from '@vueuse/core'
2026-01-05 00:38:23 +08:00
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
2026-01-04 22:29:19 +08:00
import { useTableLoader } from '@/composables/useTableLoader'
2026-01-05 00:38:23 +08:00
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'
2026-02-05 17:46:08 +08:00
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
2026-01-05 00:38:23 +08:00
import ReAuthAccountModal from '@/components/admin/account/ReAuthAccountModal.vue'
import AccountTestModal from '@/components/admin/account/AccountTestModal.vue'
import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
2025-12-18 13:50:39 +08:00
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
2026-01-05 00:38:23 +08:00
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
2026-01-12 16:08:44 +08:00
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
2026-01-16 23:36:52 +08:00
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
2026-01-05 00:38:23 +08:00
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
2026-01-18 11:57:07 +08:00
import Icon from '@/components/icons/Icon.vue'
2026-01-07 16:59:35 +08:00
import { formatDateTime , formatRelativeTime } from '@/utils/format'
2026-01-19 18:58:42 +08:00
import type { Account , Proxy , AdminGroup } from '@/types'
2025-12-18 13:50:39 +08:00
2026-01-05 00:38:23 +08:00
const { t } = useI18n ()
const appStore = useAppStore ()
const authStore = useAuthStore ()
const proxies = ref < Proxy [] >([])
2026-01-19 18:58:42 +08:00
const groups = ref < AdminGroup [] >([])
2026-01-05 00:38:23 +08:00
const selIds = ref < number [] >([])
const showCreate = ref ( false )
const showEdit = ref ( false )
const showSync = ref ( false )
2026-02-05 17:46:08 +08:00
const showImportData = ref ( false )
const showExportDataDialog = ref ( false )
const includeProxyOnExport = ref ( true )
2026-01-05 00:38:23 +08:00
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 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 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 })
2026-02-05 17:46:08 +08:00
const exportingData = ref ( false )
2026-01-04 22:29:19 +08:00
2026-01-18 11:57:07 +08:00
// Column settings
const showColumnDropdown = ref ( false )
const columnDropdownRef = ref < HTMLElement | null >( null )
const hiddenColumns = reactive < Set < string > > ( new Set ())
const DEFAULT_HIDDEN_COLUMNS = [ 'proxy' , 'notes' , 'priority' , 'rate_multiplier' ]
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
2026-01-21 22:43:25 +08:00
// 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 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`
}
2026-01-18 11:57:07 +08:00
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 )
}
}
2026-01-21 22:43:25 +08:00
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
}
}
2026-01-18 11:57:07 +08:00
const toggleColumn = ( key : string ) => {
if ( hiddenColumns . has ( key )) {
hiddenColumns . delete ( key )
} else {
hiddenColumns . add ( key )
}
saveColumnsToStorage ()
}
const isColumnVisible = ( key : string ) => ! hiddenColumns . has ( key )
2026-01-09 17:38:45 +08:00
const { items : accounts , loading , params , pagination , load , reload , debouncedReload , handlePageChange , handlePageSizeChange } = useTableLoader < Account , any >({
2026-01-04 22:29:19 +08:00
fetchFn : adminAPI . accounts . list ,
2026-01-05 00:38:23 +08:00
initialParams : { platform : '' , type : '' , status : '' , search : '' }
2026-01-04 22:29:19 +08:00
})
2025-12-18 13:50:39 +08:00
2026-01-21 22:43:25 +08:00
const isAnyModalOpen = computed (() => {
return (
showCreate . value ||
showEdit . value ||
showSync . value ||
2026-02-05 17:46:08 +08:00
showImportData . value ||
showExportDataDialog . value ||
2026-01-21 22:43:25 +08:00
showBulkEdit . value ||
showTempUnsched . value ||
showDeleteDialog . value ||
showReAuth . value ||
showTest . value ||
showStats . value
)
})
const { pause : pauseAutoRefresh , resume : resumeAutoRefresh } = useIntervalFn (
async () => {
if ( ! autoRefreshEnabled . value ) return
if ( document . hidden ) return
if ( loading . value ) return
if ( isAnyModalOpen . value ) return
if ( menu . show ) return
if ( autoRefreshCountdown . value <= 0 ) {
autoRefreshCountdown . value = autoRefreshIntervalSeconds . value
try {
await load ()
} catch ( e ) {
console . error ( 'Auto refresh failed:' , e )
}
return
}
autoRefreshCountdown . value -= 1
},
1000 ,
{ immediate : false }
)
2026-01-18 11:57:07 +08:00
// All available columns
const allColumns = computed (() => {
2026-01-05 00:38:23 +08:00
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 },
2026-01-16 23:36:52 +08:00
{ key : 'capacity' , label : t ( 'admin.accounts.columns.capacity' ), sortable : false },
2026-01-05 00:38:23 +08:00
{ 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 })
}
2026-01-18 11:57:07 +08:00
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 },
2026-01-07 16:59:35 +08:00
{ key : 'expires_at' , label : t ( 'admin.accounts.columns.expiresAt' ), sortable : true },
2026-01-05 14:07:33 +08:00
{ key : 'notes' , label : t ( 'admin.accounts.columns.notes' ), sortable : false },
2026-01-05 00:38:23 +08:00
{ key : 'actions' , label : t ( 'admin.accounts.columns.actions' ), sortable : false }
)
return c
})
2025-12-29 03:17:25 +08:00
2026-01-18 11:57:07 +08:00
// 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 )
)
)
2026-01-04 22:17:27 +08:00
const handleEdit = ( a : Account ) => { edAcc . value = a ; showEdit . value = true }
2026-01-15 15:14:44 +08:00
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
}
2026-01-04 22:17:27 +08:00
const toggleSel = ( id : number ) => { const i = selIds . value . indexOf ( id ); if ( i === - 1 ) selIds . value . push ( id ); else selIds . value . splice ( i , 1 ) }
2026-01-05 00:38:23 +08:00
const selectPage = () => { selIds . value = [... new Set ([... selIds . value , ... accounts . value . map ( a => a . id )])] }
2026-01-06 12:42:06 +08:00
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 ) } }
2026-01-11 20:22:17 +08:00
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
}
}
2026-01-09 19:26:32 +08:00
const handleBulkToggleSchedulable = async ( schedulable : boolean ) => {
2026-01-11 20:22:17 +08:00
const accountIds = [... selIds . value ]
2026-01-09 19:26:32 +08:00
try {
2026-01-11 20:22:17 +08:00
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
}
2026-01-09 19:26:32 +08:00
} catch ( error ) {
2026-01-11 20:22:17 +08:00
console . error ( 'Failed to bulk toggle schedulable:' , error )
2026-01-09 19:26:32 +08:00
appStore . showError ( t ( 'common.error' ))
}
}
2026-01-04 22:29:19 +08:00
const handleBulkUpdated = () => { showBulkEdit . value = false ; selIds . value = []; reload () }
2026-02-05 17:46:08 +08:00
const handleDataImported = () => { showImportData . value = false ; reload () }
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
}
}
2026-01-05 00:38:23 +08:00
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 handleReAuth = ( a : Account ) => { reAuthAcc . value = a ; showReAuth . value = true }
2026-01-06 12:42:06 +08:00
const handleRefresh = async ( a : Account ) => { try { await adminAPI . accounts . refreshCredentials ( a . id ); load () } catch ( error ) { console . error ( 'Failed to refresh credentials:' , error ) } }
const handleResetStatus = async ( a : Account ) => { try { await adminAPI . accounts . clearError ( a . id ); appStore . showSuccess ( t ( 'common.success' )); load () } catch ( error ) { console . error ( 'Failed to reset status:' , error ) } }
2026-01-06 12:50:51 +08:00
const handleClearRateLimit = async ( a : Account ) => { try { await adminAPI . accounts . clearRateLimit ( a . id ); appStore . showSuccess ( t ( 'common.success' )); load () } catch ( error ) { console . error ( 'Failed to clear rate limit:' , error ) } }
2026-01-05 00:38:23 +08:00
const handleDelete = ( a : Account ) => { deletingAcc . value = a ; showDeleteDialog . value = true }
2026-01-06 12:42:06 +08:00
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 ) } }
2026-01-11 20:22:17 +08:00
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 )
} catch ( error ) {
console . error ( 'Failed to toggle schedulable:' , error )
appStore . showError ( t ( 'admin.accounts.failedToToggleSchedulable' ))
} finally {
togglingSchedulable . value = null
}
}
2026-01-05 00:38:23 +08:00
const handleShowTempUnsched = ( a : Account ) => { tempUnschedAcc . value = a ; showTempUnsched . value = true }
2026-01-06 12:42:06 +08:00
const handleTempUnschedReset = async () => { if ( ! tempUnschedAcc . value ) return ; try { await adminAPI . accounts . clearError ( tempUnschedAcc . value . id ); showTempUnsched . value = false ; tempUnschedAcc . value = null ; load () } catch ( error ) { console . error ( 'Failed to reset temp unscheduled:' , error ) } }
2026-01-07 16:59:35 +08:00
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 ()
}
2025-12-18 13:50:39 +08:00
2026-01-18 11:57:07 +08:00
// 滚动时关闭操作菜单(不关闭列设置下拉菜单)
2026-01-15 15:14:44 +08:00
const handleScroll = () => {
menu . show = false
}
2026-01-18 11:57:07 +08:00
// 点击外部关闭列设置下拉菜单
const handleClickOutside = ( event : MouseEvent ) => {
const target = event . target as HTMLElement
if ( columnDropdownRef . value && ! columnDropdownRef . value . contains ( target )) {
showColumnDropdown . value = false
}
2026-01-21 22:43:25 +08:00
if ( autoRefreshDropdownRef . value && ! autoRefreshDropdownRef . value . contains ( target )) {
showAutoRefreshDropdown . value = false
}
2026-01-18 11:57:07 +08:00
}
2026-01-15 22:07:23 +08:00
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 )
2026-01-18 11:57:07 +08:00
document . addEventListener ( 'click' , handleClickOutside )
2026-01-21 22:43:25 +08:00
if ( autoRefreshEnabled . value ) {
autoRefreshCountdown . value = autoRefreshIntervalSeconds . value
resumeAutoRefresh ()
} else {
pauseAutoRefresh ()
}
2026-01-15 22:07:23 +08:00
})
2026-01-15 15:14:44 +08:00
onUnmounted (() => {
window . removeEventListener ( 'scroll' , handleScroll , true )
2026-01-18 11:57:07 +08:00
document . removeEventListener ( 'click' , handleClickOutside )
2026-01-15 15:14:44 +08:00
})
2026-01-05 00:38:23 +08:00
</ script >