Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
4bd3dbf2
Unverified
Commit
4bd3dbf2
authored
Jan 22, 2026
by
Wesley Liddick
Committed by
GitHub
Jan 22, 2026
Browse files
Merge pull request #354 from DuckyProject/fix/frontend-table
feat(frontend): 账号表格默认排序/持久化 + 自动刷新 + 更多菜单外部关闭
parents
226df1c2
ff74f517
Changes
6
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/admin/account/AccountActionMenu.vue
View file @
4bd3dbf2
<
template
>
<
template
>
<Teleport
to=
"body"
>
<Teleport
to=
"body"
>
<div
v-if=
"show && position"
class=
"action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
:style=
"
{ top: position.top + 'px', left: position.left + 'px' }">
<div
v-if=
"show && position"
>
<div
class=
"py-1"
>
<!-- Backdrop: click anywhere outside to close -->
<template
v-if=
"account"
>
<div
class=
"fixed inset-0 z-[9998]"
@
click=
"emit('close')"
></div>
<button
@
click=
"$emit('test', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
<div
<Icon
name=
"play"
size=
"sm"
class=
"text-green-500"
:stroke-width=
"2"
/>
class=
"action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
{{
t
(
'
admin.accounts.testConnection
'
)
}}
:style=
"
{ top: position.top + 'px', left: position.left + 'px' }"
</button>
@click.stop
<button
@
click=
"$emit('stats', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
>
<Icon
name=
"chart"
size=
"sm"
class=
"text-indigo-500"
/>
<div
class=
"py-1"
>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
<template
v-if=
"account"
>
</button>
<button
@
click=
"$emit('test', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
<template
v-if=
"account.type === 'oauth' || account.type === 'setup-token'"
>
<Icon
name=
"play"
size=
"sm"
class=
"text-green-500"
:stroke-width=
"2"
/>
<button
@
click=
"$emit('reauth', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
<Icon
name=
"link"
size=
"sm"
/>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
</button>
</button>
<button
@
click=
"$emit('refresh-token', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<button
@
click=
"$emit('stats', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"refresh"
size=
"sm"
/>
<Icon
name=
"chart"
size=
"sm"
class=
"text-indigo-500"
/>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
{{
t
(
'
admin.accounts.viewStats
'
)
}}
</button>
<template
v-if=
"account.type === 'oauth' || account.type === 'setup-token'"
>
<button
@
click=
"$emit('reauth', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"link"
size=
"sm"
/>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
</button>
<button
@
click=
"$emit('refresh-token', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"refresh"
size=
"sm"
/>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
</button>
</
template
>
<div
v-if=
"account.status === 'error' || isRateLimited || isOverloaded"
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<button
v-if=
"account.status === 'error'"
@
click=
"$emit('reset-status', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"sync"
size=
"sm"
/>
{{ t('admin.accounts.resetStatus') }}
</button>
<button
v-if=
"isRateLimited || isOverloaded"
@
click=
"$emit('clear-rate-limit', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"clock"
size=
"sm"
/>
{{ t('admin.accounts.clearRateLimit') }}
</button>
</button>
</template>
</template>
<div
v-if=
"account.status === 'error' || isRateLimited || isOverloaded"
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
</div>
<button
v-if=
"account.status === 'error'"
@
click=
"$emit('reset-status', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"sync"
size=
"sm"
/>
{{ t('admin.accounts.resetStatus') }}
</button>
<button
v-if=
"isRateLimited || isOverloaded"
@
click=
"$emit('clear-rate-limit', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"clock"
size=
"sm"
/>
{{ t('admin.accounts.clearRateLimit') }}
</button>
</template>
</div>
</div>
</div>
</div>
</Teleport>
</Teleport>
</template>
</template>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
,
watch
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Icon
}
from
'
@/components/icons
'
import
{
Icon
}
from
'
@/components/icons
'
import
type
{
Account
}
from
'
@/types
'
import
type
{
Account
}
from
'
@/types
'
const
props
=
defineProps
<
{
show
:
boolean
;
account
:
Account
|
null
;
position
:
{
top
:
number
;
left
:
number
}
|
null
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
;
account
:
Account
|
null
;
position
:
{
top
:
number
;
left
:
number
}
|
null
}
>
()
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
emit
=
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
isRateLimited
=
computed
(()
=>
props
.
account
?.
rate_limit_reset_at
&&
new
Date
(
props
.
account
.
rate_limit_reset_at
)
>
new
Date
())
const
isRateLimited
=
computed
(()
=>
props
.
account
?.
rate_limit_reset_at
&&
new
Date
(
props
.
account
.
rate_limit_reset_at
)
>
new
Date
())
const
isOverloaded
=
computed
(()
=>
props
.
account
?.
overload_until
&&
new
Date
(
props
.
account
.
overload_until
)
>
new
Date
())
const
isOverloaded
=
computed
(()
=>
props
.
account
?.
overload_until
&&
new
Date
(
props
.
account
.
overload_until
)
>
new
Date
())
const
handleKeydown
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
)
emit
(
'
close
'
)
}
watch
(
()
=>
props
.
show
,
(
visible
)
=>
{
if
(
visible
)
{
window
.
addEventListener
(
'
keydown
'
,
handleKeydown
)
}
else
{
window
.
removeEventListener
(
'
keydown
'
,
handleKeydown
)
}
},
{
immediate
:
true
}
)
onUnmounted
(()
=>
{
window
.
removeEventListener
(
'
keydown
'
,
handleKeydown
)
})
</
script
>
</
script
>
frontend/src/components/common/DataTable.vue
View file @
4bd3dbf2
...
@@ -279,18 +279,143 @@ interface Props {
...
@@ -279,18 +279,143 @@ interface Props {
expandableActions
?:
boolean
expandableActions
?:
boolean
actionsCount
?:
number
// 操作按钮总数,用于判断是否需要展开功能
actionsCount
?:
number
// 操作按钮总数,用于判断是否需要展开功能
rowKey
?:
string
|
((
row
:
any
)
=>
string
|
number
)
rowKey
?:
string
|
((
row
:
any
)
=>
string
|
number
)
/**
* Default sort configuration (only applied when there is no persisted sort state)
*/
defaultSortKey
?:
string
defaultSortOrder
?:
'
asc
'
|
'
desc
'
/**
* Persist sort state (key + order) to localStorage using this key.
* If provided, DataTable will load the stored sort state on mount.
*/
sortStorageKey
?:
string
}
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
loading
:
false
,
loading
:
false
,
stickyFirstColumn
:
true
,
stickyFirstColumn
:
true
,
stickyActionsColumn
:
true
,
stickyActionsColumn
:
true
,
expandableActions
:
true
expandableActions
:
true
,
defaultSortOrder
:
'
asc
'
})
})
const
sortKey
=
ref
<
string
>
(
''
)
const
sortKey
=
ref
<
string
>
(
''
)
const
sortOrder
=
ref
<
'
asc
'
|
'
desc
'
>
(
'
asc
'
)
const
sortOrder
=
ref
<
'
asc
'
|
'
desc
'
>
(
'
asc
'
)
const
actionsExpanded
=
ref
(
false
)
const
actionsExpanded
=
ref
(
false
)
type
PersistedSortState
=
{
key
:
string
order
:
'
asc
'
|
'
desc
'
}
const
collator
=
new
Intl
.
Collator
(
undefined
,
{
numeric
:
true
,
sensitivity
:
'
base
'
})
const
getSortableKeys
=
()
=>
{
const
keys
=
new
Set
<
string
>
()
for
(
const
col
of
props
.
columns
)
{
if
(
col
.
sortable
)
keys
.
add
(
col
.
key
)
}
return
keys
}
const
normalizeSortKey
=
(
candidate
:
string
)
=>
{
if
(
!
candidate
)
return
''
const
sortableKeys
=
getSortableKeys
()
return
sortableKeys
.
has
(
candidate
)
?
candidate
:
''
}
const
normalizeSortOrder
=
(
candidate
:
any
):
'
asc
'
|
'
desc
'
=>
{
return
candidate
===
'
desc
'
?
'
desc
'
:
'
asc
'
}
const
readPersistedSortState
=
():
PersistedSortState
|
null
=>
{
if
(
!
props
.
sortStorageKey
)
return
null
try
{
const
raw
=
localStorage
.
getItem
(
props
.
sortStorageKey
)
if
(
!
raw
)
return
null
const
parsed
=
JSON
.
parse
(
raw
)
as
Partial
<
PersistedSortState
>
const
key
=
normalizeSortKey
(
typeof
parsed
.
key
===
'
string
'
?
parsed
.
key
:
''
)
if
(
!
key
)
return
null
return
{
key
,
order
:
normalizeSortOrder
(
parsed
.
order
)
}
}
catch
(
e
)
{
console
.
error
(
'
[DataTable] Failed to read persisted sort state:
'
,
e
)
return
null
}
}
const
writePersistedSortState
=
(
state
:
PersistedSortState
)
=>
{
if
(
!
props
.
sortStorageKey
)
return
try
{
localStorage
.
setItem
(
props
.
sortStorageKey
,
JSON
.
stringify
(
state
))
}
catch
(
e
)
{
console
.
error
(
'
[DataTable] Failed to persist sort state:
'
,
e
)
}
}
const
resolveInitialSortState
=
():
PersistedSortState
|
null
=>
{
const
persisted
=
readPersistedSortState
()
if
(
persisted
)
return
persisted
const
key
=
normalizeSortKey
(
props
.
defaultSortKey
||
''
)
if
(
!
key
)
return
null
return
{
key
,
order
:
normalizeSortOrder
(
props
.
defaultSortOrder
)
}
}
const
applySortState
=
(
state
:
PersistedSortState
|
null
)
=>
{
if
(
!
state
)
return
sortKey
.
value
=
state
.
key
sortOrder
.
value
=
state
.
order
}
const
isNullishOrEmpty
=
(
value
:
any
)
=>
value
===
null
||
value
===
undefined
||
value
===
''
const
toFiniteNumberOrNull
=
(
value
:
any
):
number
|
null
=>
{
if
(
typeof
value
===
'
number
'
)
return
Number
.
isFinite
(
value
)
?
value
:
null
if
(
typeof
value
===
'
boolean
'
)
return
value
?
1
:
0
if
(
typeof
value
===
'
string
'
)
{
const
trimmed
=
value
.
trim
()
if
(
!
trimmed
)
return
null
const
n
=
Number
(
trimmed
)
return
Number
.
isFinite
(
n
)
?
n
:
null
}
return
null
}
const
toSortableString
=
(
value
:
any
):
string
=>
{
if
(
value
===
null
||
value
===
undefined
)
return
''
if
(
typeof
value
===
'
string
'
)
return
value
if
(
typeof
value
===
'
number
'
||
typeof
value
===
'
boolean
'
)
return
String
(
value
)
if
(
value
instanceof
Date
)
return
value
.
toISOString
()
try
{
return
JSON
.
stringify
(
value
)
}
catch
{
return
String
(
value
)
}
}
const
compareSortValues
=
(
a
:
any
,
b
:
any
):
number
=>
{
const
aEmpty
=
isNullishOrEmpty
(
a
)
const
bEmpty
=
isNullishOrEmpty
(
b
)
if
(
aEmpty
&&
bEmpty
)
return
0
if
(
aEmpty
)
return
1
if
(
bEmpty
)
return
-
1
const
aNum
=
toFiniteNumberOrNull
(
a
)
const
bNum
=
toFiniteNumberOrNull
(
b
)
if
(
aNum
!==
null
&&
bNum
!==
null
)
{
if
(
aNum
===
bNum
)
return
0
return
aNum
<
bNum
?
-
1
:
1
}
const
aStr
=
toSortableString
(
a
)
const
bStr
=
toSortableString
(
b
)
const
res
=
collator
.
compare
(
aStr
,
bStr
)
if
(
res
===
0
)
return
0
return
res
<
0
?
-
1
:
1
}
const
resolveRowKey
=
(
row
:
any
,
index
:
number
)
=>
{
const
resolveRowKey
=
(
row
:
any
,
index
:
number
)
=>
{
if
(
typeof
props
.
rowKey
===
'
function
'
)
{
if
(
typeof
props
.
rowKey
===
'
function
'
)
{
const
key
=
props
.
rowKey
(
row
)
const
key
=
props
.
rowKey
(
row
)
...
@@ -334,15 +459,18 @@ const handleSort = (key: string) => {
...
@@ -334,15 +459,18 @@ const handleSort = (key: string) => {
const
sortedData
=
computed
(()
=>
{
const
sortedData
=
computed
(()
=>
{
if
(
!
sortKey
.
value
||
!
props
.
data
)
return
props
.
data
if
(
!
sortKey
.
value
||
!
props
.
data
)
return
props
.
data
return
[...
props
.
data
].
sort
((
a
,
b
)
=>
{
const
key
=
sortKey
.
value
const
aVal
=
a
[
sortKey
.
value
]
const
order
=
sortOrder
.
value
const
bVal
=
b
[
sortKey
.
value
]
if
(
aVal
===
bVal
)
return
0
const
comparison
=
aVal
>
bVal
?
1
:
-
1
// Stable sort (tie-break with original index) to avoid jitter when values are equal.
return
sortOrder
.
value
===
'
asc
'
?
comparison
:
-
comparison
return
props
.
data
})
.
map
((
row
,
index
)
=>
({
row
,
index
}))
.
sort
((
a
,
b
)
=>
{
const
cmp
=
compareSortValues
(
a
.
row
?.[
key
],
b
.
row
?.[
key
])
if
(
cmp
!==
0
)
return
order
===
'
asc
'
?
cmp
:
-
cmp
return
a
.
index
-
b
.
index
})
.
map
(
item
=>
item
.
row
)
})
})
const
hasActionsColumn
=
computed
(()
=>
{
const
hasActionsColumn
=
computed
(()
=>
{
...
@@ -396,6 +524,51 @@ const getAdaptivePaddingClass = () => {
...
@@ -396,6 +524,51 @@ const getAdaptivePaddingClass = () => {
return
'
px-6
'
// 24px (原始值)
return
'
px-6
'
// 24px (原始值)
}
}
}
}
// Init + keep persisted sort state consistent with current columns
const
didInitSort
=
ref
(
false
)
onMounted
(()
=>
{
const
initial
=
resolveInitialSortState
()
applySortState
(
initial
)
didInitSort
.
value
=
true
})
watch
(
()
=>
props
.
columns
,
()
=>
{
// If current sort key is no longer sortable/visible, fall back to default/persisted.
const
normalized
=
normalizeSortKey
(
sortKey
.
value
)
if
(
!
sortKey
.
value
)
{
const
initial
=
resolveInitialSortState
()
applySortState
(
initial
)
return
}
if
(
!
normalized
)
{
const
fallback
=
resolveInitialSortState
()
if
(
fallback
)
{
applySortState
(
fallback
)
}
else
{
sortKey
.
value
=
''
sortOrder
.
value
=
'
asc
'
}
}
},
{
deep
:
true
}
)
watch
(
[
sortKey
,
sortOrder
],
([
nextKey
,
nextOrder
])
=>
{
if
(
!
didInitSort
.
value
)
return
if
(
!
props
.
sortStorageKey
)
return
const
key
=
normalizeSortKey
(
nextKey
)
if
(
!
key
)
return
writePersistedSortState
({
key
,
order
:
normalizeSortOrder
(
nextOrder
)
})
},
{
flush
:
'
post
'
}
)
</
script
>
</
script
>
<
style
scoped
>
<
style
scoped
>
...
...
frontend/src/components/common/README.md
View file @
4bd3dbf2
...
@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
...
@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
-
`columns: Column[]`
- Array of column definitions with key, label, sortable, and formatter
-
`columns: Column[]`
- Array of column definitions with key, label, sortable, and formatter
-
`data: any[]`
- Array of data objects to display
-
`data: any[]`
- Array of data objects to display
-
`loading?: boolean`
- Show loading skeleton
-
`loading?: boolean`
- Show loading skeleton
-
`defaultSortKey?: string`
- Default sort key (only used if no persisted sort state)
-
`defaultSortOrder?: 'asc' | 'desc'`
- Default sort order (default:
`asc`
)
-
`sortStorageKey?: string`
- Persist sort state (key + order) to localStorage
-
`rowKey?: string | (row: any) => string | number`
- Row key field or resolver (defaults to
`row.id`
, falls back to index)
-
`rowKey?: string | (row: any) => string | number`
- Row key field or resolver (defaults to
`row.id`
, falls back to index)
**Slots:**
**Slots:**
...
...
frontend/src/i18n/locales/en.ts
View file @
4bd3dbf2
...
@@ -1022,6 +1022,13 @@ export default {
...
@@ -1022,6 +1022,13 @@ export default {
title
:
'
Account Management
'
,
title
:
'
Account Management
'
,
description
:
'
Manage AI platform accounts and credentials
'
,
description
:
'
Manage AI platform accounts and credentials
'
,
createAccount
:
'
Create Account
'
,
createAccount
:
'
Create Account
'
,
autoRefresh
:
'
Auto Refresh
'
,
enableAutoRefresh
:
'
Enable auto refresh
'
,
refreshInterval5s
:
'
5 seconds
'
,
refreshInterval10s
:
'
10 seconds
'
,
refreshInterval15s
:
'
15 seconds
'
,
refreshInterval30s
:
'
30 seconds
'
,
autoRefreshCountdown
:
'
Auto refresh: {seconds}s
'
,
syncFromCrs
:
'
Sync from CRS
'
,
syncFromCrs
:
'
Sync from CRS
'
,
syncFromCrsTitle
:
'
Sync Accounts from CRS
'
,
syncFromCrsTitle
:
'
Sync Accounts from CRS
'
,
syncFromCrsDesc
:
syncFromCrsDesc
:
...
...
frontend/src/i18n/locales/zh.ts
View file @
4bd3dbf2
...
@@ -1096,6 +1096,13 @@ export default {
...
@@ -1096,6 +1096,13 @@ export default {
title
:
'
账号管理
'
,
title
:
'
账号管理
'
,
description
:
'
管理 AI 平台账号和 Cookie
'
,
description
:
'
管理 AI 平台账号和 Cookie
'
,
createAccount
:
'
添加账号
'
,
createAccount
:
'
添加账号
'
,
autoRefresh
:
'
自动刷新
'
,
enableAutoRefresh
:
'
启用自动刷新
'
,
refreshInterval5s
:
'
5 秒
'
,
refreshInterval10s
:
'
10 秒
'
,
refreshInterval15s
:
'
15 秒
'
,
refreshInterval30s
:
'
30 秒
'
,
autoRefreshCountdown
:
'
自动刷新:{seconds}s
'
,
syncFromCrs
:
'
从 CRS 同步
'
,
syncFromCrs
:
'
从 CRS 同步
'
,
syncFromCrsTitle
:
'
从 CRS 同步账号
'
,
syncFromCrsTitle
:
'
从 CRS 同步账号
'
,
syncFromCrsDesc
:
syncFromCrsDesc
:
...
...
frontend/src/views/admin/AccountsView.vue
View file @
4bd3dbf2
...
@@ -17,10 +17,58 @@
...
@@ -17,10 +17,58 @@
@
create=
"showCreate = true"
@
create=
"showCreate = true"
>
>
<template
#after
>
<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
>
<!--
Column
Settings
Dropdown
-->
<!--
Column
Settings
Dropdown
-->
<
div
class
=
"
relative
"
ref
=
"
columnDropdownRef
"
>
<
div
class
=
"
relative
"
ref
=
"
columnDropdownRef
"
>
<
button
<
button
@
click=
"showColumnDropdown = !showColumnDropdown"
@
click
=
"
showColumnDropdown = !showColumnDropdown;
showAutoRefreshDropdown = false
"
class
=
"
btn btn-secondary px-2 md:px-3
"
class
=
"
btn btn-secondary px-2 md:px-3
"
:
title
=
"
t('admin.users.columnSettings')
"
:
title
=
"
t('admin.users.columnSettings')
"
>
>
...
@@ -53,7 +101,15 @@
...
@@ -53,7 +101,15 @@
<
/template
>
<
/template
>
<
template
#
table
>
<
template
#
table
>
<
AccountBulkActionsBar
:
selected
-
ids
=
"
selIds
"
@
delete
=
"
handleBulkDelete
"
@
edit
=
"
showBulkEdit = true
"
@
clear
=
"
selIds = []
"
@
select
-
page
=
"
selectPage
"
@
toggle
-
schedulable
=
"
handleBulkToggleSchedulable
"
/>
<
AccountBulkActionsBar
:
selected
-
ids
=
"
selIds
"
@
delete
=
"
handleBulkDelete
"
@
edit
=
"
showBulkEdit = true
"
@
clear
=
"
selIds = []
"
@
select
-
page
=
"
selectPage
"
@
toggle
-
schedulable
=
"
handleBulkToggleSchedulable
"
/>
<DataTable
:columns=
"cols"
:data=
"accounts"
:loading=
"loading"
row-key=
"id"
>
<
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
#
cell
-
select
=
"
{ row
}
"
>
<
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
"
/>
<
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
>
...
@@ -161,6 +217,7 @@
...
@@ -161,6 +217,7 @@
<
script
setup
lang
=
"
ts
"
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useIntervalFn
}
from
'
@vueuse/core
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
...
@@ -221,6 +278,26 @@ const hiddenColumns = reactive<Set<string>>(new Set())
...
@@ -221,6 +278,26 @@ const hiddenColumns = reactive<Set<string>>(new Set())
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
proxy
'
,
'
notes
'
,
'
priority
'
,
'
rate_multiplier
'
]
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
proxy
'
,
'
notes
'
,
'
priority
'
,
'
rate_multiplier
'
]
const
HIDDEN_COLUMNS_KEY
=
'
account-hidden-columns
'
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
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
=
()
=>
{
const
loadSavedColumns
=
()
=>
{
try
{
try
{
const
saved
=
localStorage
.
getItem
(
HIDDEN_COLUMNS_KEY
)
const
saved
=
localStorage
.
getItem
(
HIDDEN_COLUMNS_KEY
)
...
@@ -244,6 +321,60 @@ const saveColumnsToStorage = () => {
...
@@ -244,6 +321,60 @@ const saveColumnsToStorage = () => {
}
}
}
}
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
toggleColumn
=
(
key
:
string
)
=>
{
if
(
hiddenColumns
.
has
(
key
))
{
if
(
hiddenColumns
.
has
(
key
))
{
hiddenColumns
.
delete
(
key
)
hiddenColumns
.
delete
(
key
)
...
@@ -260,6 +391,44 @@ const { items: accounts, loading, params, pagination, load, reload, debouncedRel
...
@@ -260,6 +391,44 @@ const { items: accounts, loading, params, pagination, load, reload, debouncedRel
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
search
:
''
}
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
search
:
''
}
}
)
}
)
const
isAnyModalOpen
=
computed
(()
=>
{
return
(
showCreate
.
value
||
showEdit
.
value
||
showSync
.
value
||
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
}
)
// All available columns
// All available columns
const
allColumns
=
computed
(()
=>
{
const
allColumns
=
computed
(()
=>
{
const
c
=
[
const
c
=
[
...
@@ -512,10 +681,12 @@ const handleClickOutside = (event: MouseEvent) => {
...
@@ -512,10 +681,12 @@ const handleClickOutside = (event: MouseEvent) => {
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
showColumnDropdown
.
value
=
false
showColumnDropdown
.
value
=
false
}
}
if
(
autoRefreshDropdownRef
.
value
&&
!
autoRefreshDropdownRef
.
value
.
contains
(
target
))
{
showAutoRefreshDropdown
.
value
=
false
}
}
}
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
loadSavedColumns
()
load
()
load
()
try
{
try
{
const
[
p
,
g
]
=
await
Promise
.
all
([
adminAPI
.
proxies
.
getAll
(),
adminAPI
.
groups
.
getAll
()])
const
[
p
,
g
]
=
await
Promise
.
all
([
adminAPI
.
proxies
.
getAll
(),
adminAPI
.
groups
.
getAll
()])
...
@@ -526,6 +697,13 @@ onMounted(async () => {
...
@@ -526,6 +697,13 @@ onMounted(async () => {
}
}
window
.
addEventListener
(
'
scroll
'
,
handleScroll
,
true
)
window
.
addEventListener
(
'
scroll
'
,
handleScroll
,
true
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
if
(
autoRefreshEnabled
.
value
)
{
autoRefreshCountdown
.
value
=
autoRefreshIntervalSeconds
.
value
resumeAutoRefresh
()
}
else
{
pauseAutoRefresh
()
}
}
)
}
)
onUnmounted
(()
=>
{
onUnmounted
(()
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment