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-04 21:46:23 -08:00
@change ="reload"
@ update :searchQuery = "debouncedReload"
/>
< AccountTableActions
:loading = "loading"
@refresh ="load"
@sync ="showSync = true"
@create ="showCreate = true"
/>
2026-01-05 01:00:00 +08:00
</ div >
</ template >
2025-12-27 10:50:25 +08:00
< template # table >
2026-01-05 00:38:23 +08:00
< AccountBulkActionsBar :selected-ids = "selIds" @delete ="handleBulkDelete" @edit ="showBulkEdit = true" @clear ="selIds = []" @ select -page =" selectPage " />
2026-01-04 22:17:27 +08:00
< DataTable :columns = "cols" :data = "accounts" :loading = "loading" >
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 >
< template # cell -name =" { value } " >
< span class = "font-medium text-gray-900 dark:text-white" >{{ value }}</ span >
</ 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 >
< template # cell -concurrency =" { row } " >
< div class = "flex items-center gap-1.5" >
< span : class = "['inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium', (row.current_concurrency || 0) >= row.concurrency ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' : (row.current_concurrency || 0) > 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400']" >
< svg class = "h-3 w-3" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" stroke -width =" 2 " >< path stroke -linecap =" round " stroke -linejoin =" round " d = "M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" /></ svg >
< span class = "font-mono" >{{ row . current_concurrency || 0 }}</ span >
< span class = "text-gray-400 dark:text-gray-500" > / </ span >
< span class = "font-mono" >{{ row . concurrency }}</ span >
</ span >
</ div >
</ 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 } " >
< div v-if = "row.groups && row.groups.length > 0" class="flex flex-wrap gap-1.5" >
< GroupBadge v-for = "group in row.groups" :key="group.id" :name="group.name" :platform="group.platform" :subscription-type="group.subscription_type" :rate-multiplier="group.rate_multiplier" :show-rate="false" />
</ div >
< span v-else class = "text-sm text-gray-400 dark:text-dark-500" > - </ span >
</ template >
< template # cell -usage =" { row } " >
< AccountUsageCell :account = "row" />
</ 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 >
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-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" />
2025-12-18 13:50:39 +08:00
</ AppLayout >
</ template >
< script setup lang = "ts" >
2026-01-05 00:38:23 +08:00
import { ref , reactive , computed , onMounted } from 'vue'
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'
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'
import GroupBadge from '@/components/common/GroupBadge.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
2026-01-07 16:59:35 +08:00
import { formatDateTime , formatRelativeTime } from '@/utils/format'
2026-01-04 22:17:27 +08:00
import type { Account , Proxy , Group } 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 [] >([])
const groups = ref < Group [] >([])
const selIds = ref < number [] >([])
const showCreate = ref ( false )
const showEdit = ref ( false )
const showSync = ref ( false )
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-01-04 22:29:19 +08:00
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-05 00:38:23 +08:00
const cols = 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 : 'concurrency' , label : t ( 'admin.accounts.columns.concurrencyStatus' ), 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 : 'priority' , label : t ( 'admin.accounts.columns.priority' ), 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-04 22:17:27 +08:00
const handleEdit = ( a : Account ) => { edAcc . value = a ; showEdit . value = true }
const openMenu = ( a : Account , e : MouseEvent ) => { menu . acc = a ; 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 ) }
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-04 22:29:19 +08:00
const handleBulkUpdated = () => { showBulkEdit . value = false ; selIds . value = []; reload () }
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-05 20:48:12 +08:00
const handleToggleSchedulable = async ( a : Account ) => { togglingSchedulable . value = a . id ; try { await adminAPI . accounts . setSchedulable ( a . id , ! a . schedulable ); load () } 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-06 12:42:06 +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 ) } })
2026-01-05 00:38:23 +08:00
</ script >