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
1ef3782d
Unverified
Commit
1ef3782d
authored
Apr 11, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 11, 2026
Browse files
Merge pull request #1538 from IanShaw027/fix/bug-cleanup-main
fix: 修复多个 UI 和功能问题 - 表格排序搜索、导出逻辑、分页配置和状态筛选
parents
00c08c57
f480e573
Changes
117
Show whitespace changes
Inline
Side-by-side
frontend/src/utils/tablePreferences.ts
0 → 100644
View file @
1ef3782d
const
MIN_TABLE_PAGE_SIZE
=
5
const
MAX_TABLE_PAGE_SIZE
=
1000
export
const
DEFAULT_TABLE_PAGE_SIZE
=
20
export
const
DEFAULT_TABLE_PAGE_SIZE_OPTIONS
=
[
10
,
20
,
50
,
100
]
const
sanitizePageSize
=
(
value
:
unknown
):
number
|
null
=>
{
const
size
=
Number
(
value
)
if
(
!
Number
.
isInteger
(
size
))
return
null
if
(
size
<
MIN_TABLE_PAGE_SIZE
||
size
>
MAX_TABLE_PAGE_SIZE
)
return
null
return
size
}
const
parsePageSizeForSelection
=
(
value
:
unknown
):
number
|
null
=>
{
const
size
=
Number
(
value
)
if
(
!
Number
.
isInteger
(
size
))
return
null
if
(
size
<
MIN_TABLE_PAGE_SIZE
)
return
null
return
size
}
const
getInjectedAppConfig
=
()
=>
{
if
(
typeof
window
===
'
undefined
'
)
return
null
return
window
.
__APP_CONFIG__
??
null
}
const
getSanitizedConfiguredOptions
=
():
number
[]
=>
{
const
configured
=
getInjectedAppConfig
()?.
table_page_size_options
if
(
!
Array
.
isArray
(
configured
))
return
[]
return
Array
.
from
(
new
Set
(
configured
.
map
((
value
)
=>
sanitizePageSize
(
value
))
.
filter
((
value
):
value
is
number
=>
value
!==
null
)
)
).
sort
((
a
,
b
)
=>
a
-
b
)
}
const
normalizePageSizeToOptions
=
(
value
:
number
,
options
:
number
[]):
number
=>
{
for
(
const
option
of
options
)
{
if
(
option
>=
value
)
{
return
option
}
}
return
options
[
options
.
length
-
1
]
}
export
const
getConfiguredTableDefaultPageSize
=
():
number
=>
{
const
configured
=
sanitizePageSize
(
getInjectedAppConfig
()?.
table_default_page_size
)
if
(
configured
===
null
)
{
return
DEFAULT_TABLE_PAGE_SIZE
}
return
configured
}
export
const
getConfiguredTablePageSizeOptions
=
():
number
[]
=>
{
const
unique
=
getSanitizedConfiguredOptions
()
if
(
unique
.
length
===
0
)
{
return
[...
DEFAULT_TABLE_PAGE_SIZE_OPTIONS
]
}
return
unique
.
length
>
0
?
unique
:
[...
DEFAULT_TABLE_PAGE_SIZE_OPTIONS
]
}
export
const
normalizeTablePageSize
=
(
value
:
unknown
):
number
=>
{
const
normalized
=
parsePageSizeForSelection
(
value
)
const
defaultSize
=
getConfiguredTableDefaultPageSize
()
const
options
=
getConfiguredTablePageSizeOptions
()
if
(
normalized
!==
null
)
{
return
normalizePageSizeToOptions
(
normalized
,
options
)
}
return
normalizePageSizeToOptions
(
defaultSize
,
options
)
}
frontend/src/views/admin/AccountsView.vue
View file @
1ef3782d
...
...
@@ -148,6 +148,8 @@
:
data
=
"
accounts
"
:
loading
=
"
loading
"
row
-
key
=
"
id
"
:
server
-
side
-
sort
=
"
true
"
@
sort
=
"
handleSort
"
default
-
sort
-
key
=
"
name
"
default
-
sort
-
order
=
"
asc
"
:
sort
-
storage
-
key
=
"
ACCOUNT_SORT_STORAGE_KEY
"
...
...
@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings
const
ACCOUNT_SORT_STORAGE_KEY
=
'
account-table-sort
'
type
AccountSortOrder
=
'
asc
'
|
'
desc
'
type
AccountSortState
=
{
sort_by
:
string
sort_order
:
AccountSortOrder
}
const
ACCOUNT_SORTABLE_KEYS
=
new
Set
([
'
name
'
,
'
status
'
,
'
schedulable
'
,
'
priority
'
,
'
rate_multiplier
'
,
'
last_used_at
'
,
'
expires_at
'
])
const
loadInitialAccountSortState
=
():
AccountSortState
=>
{
const
fallback
:
AccountSortState
=
{
sort_by
:
'
name
'
,
sort_order
:
'
asc
'
}
try
{
const
raw
=
localStorage
.
getItem
(
ACCOUNT_SORT_STORAGE_KEY
)
if
(
!
raw
)
return
fallback
const
parsed
=
JSON
.
parse
(
raw
)
as
{
key
?:
string
;
order
?:
string
}
const
key
=
typeof
parsed
.
key
===
'
string
'
?
parsed
.
key
:
''
if
(
!
ACCOUNT_SORTABLE_KEYS
.
has
(
key
))
return
fallback
return
{
sort_by
:
key
,
sort_order
:
parsed
.
order
===
'
desc
'
?
'
desc
'
:
'
asc
'
}
}
catch
{
return
fallback
}
}
const
sortState
=
reactive
<
AccountSortState
>
(
loadInitialAccountSortState
())
// Auto refresh settings
const
showAutoRefreshDropdown
=
ref
(
false
)
...
...
@@ -594,7 +627,16 @@ const {
handlePageSizeChange
:
baseHandlePageSizeChange
}
=
useTableLoader
<
Account
,
any
>
({
fetchFn
:
adminAPI
.
accounts
.
list
,
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
privacy_mode
:
''
,
group
:
''
,
search
:
''
}
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
privacy_mode
:
''
,
group
:
''
,
search
:
''
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
}
}
)
const
{
...
...
@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => {
baseHandlePageSizeChange
(
size
)
}
const
handleSort
=
(
key
:
string
,
order
:
AccountSortOrder
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
const
requestParams
=
params
as
any
requestParams
.
sort_by
=
key
requestParams
.
sort_order
=
order
pagination
.
page
=
1
hasPendingListSync
.
value
=
false
resetAutoRefreshCache
()
pendingTodayStatsRefresh
.
value
=
true
load
()
}
watch
(
loading
,
(
isLoading
,
wasLoading
)
=>
{
if
(
wasLoading
&&
!
isLoading
&&
pendingTodayStatsRefresh
.
value
)
{
pendingTodayStatsRefresh
.
value
=
false
...
...
@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => {
privacy_mode
?:
string
group
?:
string
search
?:
string
sort_by
?:
string
sort_order
?:
AccountSortOrder
}
,
{
etag
:
autoRefreshETag
.
value
}
...
...
@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
}
const
handleBulkUpdated
=
()
=>
{
showBulkEdit
.
value
=
false
;
clearSelection
();
reload
()
}
const
handleDataImported
=
()
=>
{
showImportData
.
value
=
false
;
reload
()
}
const
ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE
=
'
ungrouped
'
const
ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE
=
'
__unset__
'
const
buildAccountQueryFilters
=
()
=>
({
platform
:
params
.
platform
||
''
,
type
:
params
.
type
||
''
,
status
:
params
.
status
||
''
,
group
:
params
.
group
||
''
,
privacy_mode
:
params
.
privacy_mode
||
''
,
search
:
params
.
search
||
''
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
}
)
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
)
{
const
filters
=
buildAccountQueryFilters
()
if
(
filters
.
platform
&&
account
.
platform
!==
filters
.
platform
)
return
false
if
(
filters
.
type
&&
account
.
type
!==
filters
.
type
)
return
false
if
(
filters
.
status
)
{
const
now
=
Date
.
now
()
const
rateLimitResetAt
=
account
.
rate_limit_reset_at
?
new
Date
(
account
.
rate_limit_reset_at
).
getTime
()
:
Number
.
NaN
const
isRateLimited
=
Number
.
isFinite
(
rateLimitResetAt
)
&&
rateLimitResetAt
>
now
const
tempUnschedUntil
=
account
.
temp_unschedulable_until
?
new
Date
(
account
.
temp_unschedulable_until
).
getTime
()
:
Number
.
NaN
const
isTempUnschedulable
=
Number
.
isFinite
(
tempUnschedUntil
)
&&
tempUnschedUntil
>
now
if
(
filters
.
status
===
'
active
'
)
{
if
(
account
.
status
!==
'
active
'
||
isRateLimited
||
isTempUnschedulable
||
!
account
.
schedulable
)
return
false
}
else
if
(
filters
.
status
===
'
rate_limited
'
)
{
if
(
account
.
status
!==
'
active
'
||
!
isRateLimited
||
isTempUnschedulable
)
return
false
}
else
if
(
filters
.
status
===
'
temp_unschedulable
'
)
{
if
(
account
.
status
!==
'
active
'
||
!
isTempUnschedulable
)
return
false
}
else
if
(
filters
.
status
===
'
unschedulable
'
)
{
if
(
account
.
status
!==
'
active
'
||
account
.
schedulable
||
isRateLimited
||
isTempUnschedulable
)
return
false
}
else
if
(
account
.
status
!==
filters
.
status
)
{
return
false
}
}
const
search
=
String
(
params
.
search
||
''
).
trim
().
toLowerCase
()
if
(
filters
.
group
)
{
const
groupIds
=
account
.
group_ids
??
account
.
groups
?.
map
((
group
)
=>
group
.
id
)
??
[]
if
(
filters
.
group
===
ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE
)
{
if
(
groupIds
.
length
>
0
)
return
false
}
else
if
(
!
groupIds
.
includes
(
Number
(
filters
.
group
)))
{
return
false
}
}
const
privacyMode
=
typeof
account
.
extra
?.
privacy_mode
===
'
string
'
?
account
.
extra
.
privacy_mode
:
''
if
(
filters
.
privacy_mode
)
{
if
(
filters
.
privacy_mode
===
ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE
)
{
if
(
privacyMode
.
trim
()
!==
''
)
return
false
}
else
if
(
privacyMode
!==
filters
.
privacy_mode
)
{
return
false
}
}
const
search
=
String
(
filters
.
search
||
''
).
trim
().
toLowerCase
()
if
(
search
&&
!
account
.
name
.
toLowerCase
().
includes
(
search
))
return
false
return
true
}
...
...
@@ -1181,12 +1277,7 @@ const handleExportData = async () => {
?
{
ids
:
selIds
.
value
,
includeProxies
:
includeProxyOnExport
.
value
}
:
{
includeProxies
:
includeProxyOnExport
.
value
,
filters
:
{
platform
:
params
.
platform
,
type
:
params
.
type
,
status
:
params
.
status
,
search
:
params
.
search
}
filters
:
buildAccountQueryFilters
()
}
)
const
timestamp
=
formatExportTimestamp
()
...
...
frontend/src/views/admin/AnnouncementsView.vue
View file @
1ef3782d
...
...
@@ -39,7 +39,15 @@
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"announcements"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"announcements"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"created_at"
default-sort-order=
"desc"
@
sort=
"handleSort"
>
<template
#cell-title
="
{ value, row }">
<div
class=
"min-w-0"
>
<div
class=
"flex items-center gap-2"
>
...
...
@@ -68,7 +76,7 @@
</span>
</
template
>
<
template
#cell-notify
M
ode=
"{ row }"
>
<
template
#cell-notify
_m
ode=
"{ row }"
>
<span
:class=
"[
'badge',
...
...
@@ -100,7 +108,7 @@
</div>
</
template
>
<
template
#cell-created
A
t=
"{ value }"
>
<
template
#cell-created
_a
t=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
formatDateTime
(
value
)
}}
</span>
</
template
>
...
...
@@ -236,7 +244,7 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
reactive
,
ref
}
from
'
vue
'
import
{
computed
,
onMounted
,
onUnmounted
,
reactive
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
getPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
...
...
@@ -276,6 +284,11 @@ const pagination = reactive({
pages
:
0
})
const
sortState
=
reactive
({
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
})
const
statusFilterOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.announcements.allStatus
'
)
},
{
value
:
'
draft
'
,
label
:
t
(
'
admin.announcements.statusLabels.draft
'
)
},
...
...
@@ -295,12 +308,12 @@ const notifyModeOptions = computed(() => [
])
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
title
'
,
label
:
t
(
'
admin.announcements.columns.title
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.announcements.columns.status
'
)
},
{
key
:
'
notify
M
ode
'
,
label
:
t
(
'
admin.announcements.columns.notifyMode
'
)
},
{
key
:
'
title
'
,
label
:
t
(
'
admin.announcements.columns.title
'
)
,
sortable
:
true
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.announcements.columns.status
'
)
,
sortable
:
true
},
{
key
:
'
notify
_m
ode
'
,
label
:
t
(
'
admin.announcements.columns.notifyMode
'
)
,
sortable
:
true
},
{
key
:
'
targeting
'
,
label
:
t
(
'
admin.announcements.columns.targeting
'
)
},
{
key
:
'
timeRange
'
,
label
:
t
(
'
admin.announcements.columns.timeRange
'
)
},
{
key
:
'
created
A
t
'
,
label
:
t
(
'
admin.announcements.columns.createdAt
'
)
},
{
key
:
'
created
_a
t
'
,
label
:
t
(
'
admin.announcements.columns.createdAt
'
)
,
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.announcements.columns.actions
'
)
}
])
...
...
@@ -321,15 +334,21 @@ const targetingSummary = (targeting: AnnouncementTargeting) => {
let
currentController
:
AbortController
|
null
=
null
async
function
loadAnnouncements
()
{
if
(
currentController
)
currentController
.
abort
()
currentController
=
new
AbortController
()
currentController
?.
abort
()
const
requestController
=
new
AbortController
()
currentController
=
requestController
const
{
signal
}
=
requestController
try
{
loading
.
value
=
true
const
res
=
await
adminAPI
.
announcements
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
})
search
:
searchQuery
.
value
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
},
{
signal
})
if
(
signal
.
aborted
||
currentController
!==
requestController
)
return
announcements
.
value
=
res
.
items
pagination
.
total
=
res
.
total
...
...
@@ -337,11 +356,21 @@ async function loadAnnouncements() {
pagination
.
page
=
res
.
page
pagination
.
page_size
=
res
.
page_size
}
catch
(
error
:
any
)
{
if
(
currentController
.
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
)
return
if
(
signal
.
aborted
||
currentController
!==
requestController
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
console
.
error
(
'
Error loading announcements:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.announcements.failedToLoad
'
))
}
finally
{
if
(
currentController
===
requestController
)
{
loading
.
value
=
false
currentController
=
null
}
}
}
...
...
@@ -361,6 +390,13 @@ function handleStatusChange() {
loadAnnouncements
()
}
function
handleSort
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadAnnouncements
()
}
let
searchDebounceTimer
:
number
|
null
=
null
function
handleSearch
()
{
if
(
searchDebounceTimer
)
window
.
clearTimeout
(
searchDebounceTimer
)
...
...
@@ -562,4 +598,9 @@ onMounted(async () => {
await
loadSubscriptionGroups
()
await
loadAnnouncements
()
})
onUnmounted
(()
=>
{
if
(
searchDebounceTimer
)
window
.
clearTimeout
(
searchDebounceTimer
)
currentController
?.
abort
()
})
</
script
>
frontend/src/views/admin/ChannelsView.vue
View file @
1ef3782d
...
...
@@ -48,7 +48,15 @@
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"channels"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"channels"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"created_at"
default-sort-order=
"desc"
@
sort=
"handleSort"
>
<template
#cell-name
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
...
...
@@ -486,6 +494,10 @@ const pagination = reactive({
page_size
:
getPersistedPageSize
(),
total
:
0
})
const
sortState
=
reactive
({
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
})
// Dialog state
const
showDialog
=
ref
(
false
)
...
...
@@ -766,7 +778,9 @@ async function loadChannels() {
try
{
const
response
=
await
adminAPI
.
channels
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
search
:
searchQuery
.
value
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
},
{
signal
:
ctrl
.
signal
})
if
(
ctrl
.
signal
.
aborted
||
abortController
!==
ctrl
)
return
...
...
@@ -825,6 +839,13 @@ function handlePageSizeChange(pageSize: number) {
loadChannels
()
}
function
handleSort
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadChannels
()
}
// ── Dialog ──
function
resetForm
()
{
form
.
name
=
''
...
...
frontend/src/views/admin/GroupsView.vue
View file @
1ef3782d
...
...
@@ -81,7 +81,15 @@
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"groups"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"groups"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"sort_order"
default-sort-order=
"asc"
@
sort=
"handleSort"
>
<template
#cell-name
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
...
...
@@ -2924,6 +2932,10 @@ const pagination = reactive({
total
:
0
,
pages
:
0
,
});
const
sortState
=
reactive
({
sort_by
:
"
sort_order
"
,
sort_order
:
"
asc
"
as
"
asc
"
|
"
desc
"
,
});
let
abortController
:
AbortController
|
null
=
null
;
...
...
@@ -3290,6 +3302,8 @@ const loadGroups = async () => {
?
filters
.
is_exclusive
===
"
true
"
:
undefined
,
search
:
searchQuery
.
value
.
trim
()
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
,
},
{
signal
},
);
...
...
@@ -3392,6 +3406,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadGroups
();
};
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
;
sortState
.
sort_order
=
order
;
pagination
.
page
=
1
;
loadGroups
();
};
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
;
createModelRoutingRules
.
value
.
forEach
((
rule
)
=>
{
...
...
frontend/src/views/admin/PromoCodesView.vue
View file @
1ef3782d
...
...
@@ -39,7 +39,15 @@
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"codes"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"codes"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"created_at"
default-sort-order=
"desc"
@
sort=
"handleSort"
>
<template
#cell-code
="
{ value }">
<div
class=
"flex items-center space-x-2"
>
<code
class=
"font-mono text-sm text-gray-900 dark:text-gray-100"
>
{{
value
}}
</code>
...
...
@@ -349,7 +357,6 @@
:page=
"usagesPage"
:total=
"usagesTotal"
:page-size=
"usagesPageSize"
:page-size-options=
"[10, 20, 50]"
@
update:page=
"handleUsagesPageChange"
@
update:page-size=
"(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }"
/>
...
...
@@ -418,6 +425,10 @@ const pagination = reactive({
page_size
:
getPersistedPageSize
(),
total
:
0
})
const
sortState
=
reactive
({
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
})
// Dialogs
const
showCreateDialog
=
ref
(
false
)
...
...
@@ -514,19 +525,29 @@ const loadCodes = async () => {
pagination
.
page_size
,
{
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
}
search
:
searchQuery
.
value
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
},
{
signal
:
currentController
.
signal
}
)
if
(
currentController
.
signal
.
aborted
)
return
if
(
currentController
.
signal
.
aborted
||
abortController
!==
currentController
)
return
codes
.
value
=
response
.
items
pagination
.
total
=
response
.
total
}
catch
(
error
:
any
)
{
if
(
currentController
.
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
)
return
if
(
currentController
.
signal
.
aborted
||
abortController
!==
currentController
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.promo.failedToLoad
'
))
console
.
error
(
'
Error loading promo codes:
'
,
error
)
}
finally
{
if
(
abortController
===
currentController
&&
!
currentController
.
signal
.
aborted
)
{
if
(
abortController
===
currentController
)
{
loading
.
value
=
false
abortController
=
null
}
...
...
@@ -553,6 +574,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadCodes
()
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadCodes
()
}
const
copyToClipboard
=
async
(
text
:
string
)
=>
{
const
success
=
await
clipboardCopy
(
text
,
t
(
'
admin.promo.copied
'
))
if
(
success
)
{
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
1ef3782d
...
...
@@ -89,7 +89,15 @@
<
template
#table
>
<div
ref=
"proxyTableRef"
class=
"flex min-h-0 flex-1 flex-col overflow-hidden"
>
<DataTable
:columns=
"columns"
:data=
"proxies"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"proxies"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"id"
default-sort-order=
"desc"
@
sort=
"handleSort"
>
<template
#header-select
>
<input
type=
"checkbox"
...
...
@@ -946,6 +954,10 @@ const pagination = reactive({
total
:
0
,
pages
:
0
}
)
const
sortState
=
reactive
({
sort_by
:
'
id
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
}
)
const
showCreateModal
=
ref
(
false
)
const
createPasswordVisible
=
ref
(
false
)
...
...
@@ -1050,6 +1062,14 @@ const toggleSelectAllVisible = (event: Event) => {
toggleVisible
(
target
.
checked
)
}
const
buildProxyQueryFilters
=
()
=>
({
protocol
:
filters
.
protocol
||
undefined
,
status
:
(
filters
.
status
||
undefined
)
as
'
active
'
|
'
inactive
'
|
undefined
,
search
:
searchQuery
.
value
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
}
)
const
loadProxies
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
...
...
@@ -1058,11 +1078,12 @@ const loadProxies = async () => {
abortController
=
currentAbortController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
proxies
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
protocol
:
filters
.
protocol
||
undefined
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
}
,
{
signal
:
currentAbortController
.
signal
}
)
const
response
=
await
adminAPI
.
proxies
.
list
(
pagination
.
page
,
pagination
.
page_size
,
buildProxyQueryFilters
(),
{
signal
:
currentAbortController
.
signal
}
)
if
(
currentAbortController
.
signal
.
aborted
||
abortController
!==
currentAbortController
)
{
return
}
...
...
@@ -1103,6 +1124,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadProxies
()
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadProxies
()
}
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
createMode
.
value
=
'
standard
'
...
...
@@ -1581,7 +1609,9 @@ const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
{
protocol
:
filters
.
protocol
||
undefined
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
search
:
searchQuery
.
value
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
}
)
result
.
push
(...
response
.
items
)
...
...
@@ -1689,11 +1719,7 @@ const handleExportData = async () => {
selectedCount
.
value
>
0
?
{
ids
:
Array
.
from
(
selectedProxyIds
.
value
)
}
:
{
filters
:
{
protocol
:
filters
.
protocol
||
undefined
,
status
:
(
filters
.
status
||
undefined
)
as
'
active
'
|
'
inactive
'
|
undefined
,
search
:
searchQuery
.
value
||
undefined
}
filters
:
buildProxyQueryFilters
()
}
)
const
timestamp
=
formatExportTimestamp
()
...
...
frontend/src/views/admin/RedeemView.vue
View file @
1ef3782d
...
...
@@ -47,7 +47,15 @@
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"codes"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"codes"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"id"
default-sort-order=
"desc"
@
sort=
"handleSort"
>
<template
#cell-code
="
{ value }">
<div
class=
"flex items-center space-x-2"
>
<code
class=
"font-mono text-sm text-gray-900 dark:text-gray-100"
>
{{
value
}}
</code>
...
...
@@ -537,6 +545,10 @@ const pagination = reactive({
total
:
0
,
pages
:
0
}
)
const
sortState
=
reactive
({
sort_by
:
'
id
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
}
)
let
abortController
:
AbortController
|
null
=
null
...
...
@@ -565,6 +577,14 @@ watch(
}
)
const
buildRedeemQueryFilters
=
()
=>
({
type
:
(
filters
.
type
||
undefined
)
as
RedeemCodeType
|
undefined
,
status
:
(
filters
.
status
||
undefined
)
as
'
used
'
|
'
expired
'
|
'
unused
'
|
undefined
,
search
:
searchQuery
.
value
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
}
)
const
loadCodes
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
...
...
@@ -576,11 +596,7 @@ const loadCodes = async () => {
const
response
=
await
adminAPI
.
redeem
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
type
:
filters
.
type
as
RedeemCodeType
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
}
,
buildRedeemQueryFilters
(),
{
signal
:
currentController
.
signal
}
...
...
@@ -629,6 +645,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadCodes
()
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadCodes
()
}
const
handleGenerateCodes
=
async
()
=>
{
// 订阅类型必须选择分组
if
(
generateForm
.
type
===
'
subscription
'
&&
!
generateForm
.
group_id
)
{
...
...
@@ -672,10 +695,7 @@ const copyToClipboard = async (text: string) => {
const
handleExportCodes
=
async
()
=>
{
try
{
const
blob
=
await
adminAPI
.
redeem
.
exportCodes
({
type
:
filters
.
type
as
RedeemCodeType
,
status
:
filters
.
status
as
any
}
)
const
blob
=
await
adminAPI
.
redeem
.
exportCodes
(
buildRedeemQueryFilters
())
// Create download link
const
url
=
window
.
URL
.
createObjectURL
(
blob
)
...
...
frontend/src/views/admin/SettingsView.vue
View file @
1ef3782d
...
...
@@ -1788,6 +1788,48 @@
<
/p
>
<
/div
>
<!--
Global
Table
Preferences
-->
<
div
class
=
"
border-t border-gray-100 pt-4 dark:border-dark-700
"
>
<
h3
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.site.tablePreferencesTitle
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.site.tablePreferencesDescription
'
)
}}
<
/p
>
<
div
class
=
"
mt-4 grid grid-cols-1 gap-6 md:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.site.tableDefaultPageSize
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.table_default_page_size
"
type
=
"
number
"
min
=
"
5
"
max
=
"
1000
"
step
=
"
1
"
class
=
"
input w-40
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.site.tableDefaultPageSizeHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.site.tablePageSizeOptions
'
)
}}
<
/label
>
<
input
v
-
model
=
"
tablePageSizeOptionsInput
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.settings.site.tablePageSizeOptionsPlaceholder')
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.site.tablePageSizeOptionsHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
Custom
Endpoints
-->
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
...
...
@@ -2445,6 +2487,7 @@ const smtpPasswordManuallyEdited = ref(false)
const
testEmailAddress
=
ref
(
''
)
const
registrationEmailSuffixWhitelistTags
=
ref
<
string
[]
>
([])
const
registrationEmailSuffixWhitelistDraft
=
ref
(
''
)
const
tablePageSizeOptionsInput
=
ref
(
'
10, 20, 50, 100
'
)
// Admin API Key 状态
const
adminApiKeyLoading
=
ref
(
true
)
...
...
@@ -2499,6 +2542,10 @@ const betaPolicyForm = reactive({
}
>
}
)
const
tablePageSizeMin
=
5
const
tablePageSizeMax
=
1000
const
tablePageSizeDefault
=
20
interface
DefaultSubscriptionGroupOption
{
value
:
number
label
:
string
...
...
@@ -2539,6 +2586,8 @@ const form = reactive<SettingsForm>({
hide_ccs_import_button
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
table_default_page_size
:
tablePageSizeDefault
,
table_page_size_options
:
[
10
,
20
,
50
,
100
],
custom_menu_items
:
[]
as
Array
<
{
id
:
string
;
label
:
string
;
icon_svg
:
string
;
url
:
string
;
visibility
:
'
user
'
|
'
admin
'
;
sort_order
:
number
}
>
,
custom_endpoints
:
[]
as
Array
<
{
name
:
string
;
endpoint
:
string
;
description
:
string
}
>
,
frontend_url
:
''
,
...
...
@@ -2762,6 +2811,35 @@ function removeEndpoint(index: number) {
form
.
custom_endpoints
.
splice
(
index
,
1
)
}
function
formatTablePageSizeOptions
(
options
:
number
[]):
string
{
return
options
.
join
(
'
,
'
)
}
function
parseTablePageSizeOptionsInput
(
raw
:
string
):
number
[]
|
null
{
const
tokens
=
raw
.
split
(
'
,
'
)
.
map
((
token
)
=>
token
.
trim
())
.
filter
((
token
)
=>
token
.
length
>
0
)
if
(
tokens
.
length
===
0
)
{
return
null
}
const
parsed
=
tokens
.
map
((
token
)
=>
Number
(
token
))
if
(
parsed
.
some
((
value
)
=>
!
Number
.
isInteger
(
value
)))
{
return
null
}
const
deduped
=
Array
.
from
(
new
Set
(
parsed
)).
sort
((
a
,
b
)
=>
a
-
b
)
if
(
deduped
.
some
((
value
)
=>
value
<
tablePageSizeMin
||
value
>
tablePageSizeMax
)
)
{
return
null
}
return
deduped
}
async
function
loadSettings
()
{
loading
.
value
=
true
loadFailed
.
value
=
false
...
...
@@ -2780,6 +2858,9 @@ async function loadSettings() {
registrationEmailSuffixWhitelistTags
.
value
=
normalizeRegistrationEmailSuffixDomains
(
settings
.
registration_email_suffix_whitelist
)
tablePageSizeOptionsInput
.
value
=
formatTablePageSizeOptions
(
Array
.
isArray
(
settings
.
table_page_size_options
)
?
settings
.
table_page_size_options
:
[
10
,
20
,
50
,
100
]
)
registrationEmailSuffixWhitelistDraft
.
value
=
''
form
.
smtp_password
=
''
smtpPasswordManuallyEdited
.
value
=
false
...
...
@@ -2826,6 +2907,37 @@ function removeDefaultSubscription(index: number) {
async
function
saveSettings
()
{
saving
.
value
=
true
try
{
const
normalizedTableDefaultPageSize
=
Math
.
floor
(
Number
(
form
.
table_default_page_size
))
if
(
!
Number
.
isInteger
(
normalizedTableDefaultPageSize
)
||
normalizedTableDefaultPageSize
<
tablePageSizeMin
||
normalizedTableDefaultPageSize
>
tablePageSizeMax
)
{
appStore
.
showError
(
t
(
'
admin.settings.site.tableDefaultPageSizeRangeError
'
,
{
min
:
tablePageSizeMin
,
max
:
tablePageSizeMax
}
)
)
return
}
const
normalizedTablePageSizeOptions
=
parseTablePageSizeOptionsInput
(
tablePageSizeOptionsInput
.
value
)
if
(
!
normalizedTablePageSizeOptions
)
{
appStore
.
showError
(
t
(
'
admin.settings.site.tablePageSizeOptionsFormatError
'
,
{
min
:
tablePageSizeMin
,
max
:
tablePageSizeMax
}
)
)
return
}
form
.
table_default_page_size
=
normalizedTableDefaultPageSize
form
.
table_page_size_options
=
normalizedTablePageSizeOptions
const
normalizedDefaultSubscriptions
=
form
.
default_subscriptions
.
filter
((
item
)
=>
item
.
group_id
>
0
&&
item
.
validity_days
>
0
)
.
map
((
item
:
DefaultSubscriptionSetting
)
=>
({
...
...
@@ -2903,6 +3015,8 @@ async function saveSettings() {
hide_ccs_import_button
:
form
.
hide_ccs_import_button
,
purchase_subscription_enabled
:
form
.
purchase_subscription_enabled
,
purchase_subscription_url
:
form
.
purchase_subscription_url
,
table_default_page_size
:
form
.
table_default_page_size
,
table_page_size_options
:
form
.
table_page_size_options
,
custom_menu_items
:
form
.
custom_menu_items
,
custom_endpoints
:
form
.
custom_endpoints
,
frontend_url
:
form
.
frontend_url
,
...
...
@@ -2961,6 +3075,9 @@ async function saveSettings() {
registrationEmailSuffixWhitelistTags
.
value
=
normalizeRegistrationEmailSuffixDomains
(
updated
.
registration_email_suffix_whitelist
)
tablePageSizeOptionsInput
.
value
=
formatTablePageSizeOptions
(
Array
.
isArray
(
updated
.
table_page_size_options
)
?
updated
.
table_page_size_options
:
[
10
,
20
,
50
,
100
]
)
registrationEmailSuffixWhitelistDraft
.
value
=
''
form
.
smtp_password
=
''
smtpPasswordManuallyEdited
.
value
=
false
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
1ef3782d
...
...
@@ -174,6 +174,8 @@
:data=
"subscriptions"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"created_at"
default-sort-order=
"desc"
@
sort=
"handleSort"
>
<template
#cell-user
="
{ row }">
...
...
frontend/src/views/admin/UsageView.vue
View file @
1ef3782d
...
...
@@ -100,7 +100,16 @@
</div>
</
template
>
</UsageFilters>
<UsageTable
:data=
"usageLogs"
:loading=
"loading"
:columns=
"visibleColumns"
@
userClick=
"handleUserClick"
/>
<UsageTable
:data=
"usageLogs"
:loading=
"loading"
:columns=
"visibleColumns"
:server-side-sort=
"true"
:default-sort-key=
"'created_at'"
:default-sort-order=
"'desc'"
@
sort=
"handleSort"
@
userClick=
"handleUserClick"
/>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</div>
</AppLayout>
...
...
@@ -219,6 +228,10 @@ const defaultRange = getLast24HoursRangeDates()
const
startDate
=
ref
(
defaultRange
.
start
);
const
endDate
=
ref
(
defaultRange
.
end
)
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
model
:
undefined
,
group_id
:
undefined
,
request_type
:
undefined
,
billing_type
:
null
,
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
})
const
pagination
=
reactive
({
page
:
1
,
page_size
:
getPersistedPageSize
(),
total
:
0
})
const
sortState
=
reactive
({
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
})
const
getSingleQueryValue
=
(
value
:
string
|
null
|
Array
<
string
|
null
>
|
undefined
):
string
|
undefined
=>
{
if
(
Array
.
isArray
(
value
))
return
value
.
find
((
item
):
item
is
string
=>
typeof
item
===
'
string
'
&&
item
.
length
>
0
)
...
...
@@ -265,12 +278,31 @@ const onDateRangeChange = (range: { startDate: string; endDate: string; preset:
applyFilters
()
}
const
buildUsageListParams
=
(
page
:
number
,
pageSize
:
number
,
exactTotal
:
boolean
):
AdminUsageQueryParams
=>
{
const
requestType
=
filters
.
value
.
request_type
const
legacyStream
=
requestType
?
requestTypeToLegacyStream
(
requestType
)
:
filters
.
value
.
stream
return
{
page
,
page_size
:
pageSize
,
exact_total
:
exactTotal
,
...
filters
.
value
,
stream
:
legacyStream
===
null
?
undefined
:
legacyStream
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
}
}
const
loadLogs
=
async
()
=>
{
abortController
?.
abort
();
const
c
=
new
AbortController
();
abortController
=
c
;
loading
.
value
=
true
try
{
const
requestType
=
filters
.
value
.
request_type
const
legacyStream
=
requestType
?
requestTypeToLegacyStream
(
requestType
)
:
filters
.
value
.
stream
const
res
=
await
adminAPI
.
usage
.
list
({
page
:
pagination
.
page
,
page_size
:
pagination
.
page_size
,
exact_total
:
false
,
...
filters
.
value
,
stream
:
legacyStream
===
null
?
undefined
:
legacyStream
},
{
signal
:
c
.
signal
})
const
res
=
await
adminAPI
.
usage
.
list
(
buildUsageListParams
(
pagination
.
page
,
pagination
.
page_size
,
false
),
{
signal
:
c
.
signal
}
)
if
(
!
c
.
signal
.
aborted
)
{
usageLogs
.
value
=
res
.
items
;
pagination
.
total
=
res
.
total
}
}
catch
(
error
:
any
)
{
if
(
error
?.
name
!==
'
AbortError
'
)
console
.
error
(
'
Failed to load usage logs:
'
,
error
)
}
finally
{
if
(
abortController
===
c
)
loading
.
value
=
false
}
}
...
...
@@ -412,6 +444,12 @@ const resetFilters = () => {
}
const
handlePageChange
=
(
p
:
number
)
=>
{
pagination
.
page
=
p
;
loadLogs
()
}
const
handlePageSizeChange
=
(
s
:
number
)
=>
{
pagination
.
page_size
=
s
;
pagination
.
page
=
1
;
loadLogs
()
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadLogs
()
}
const
cancelExport
=
()
=>
exportAbortController
?.
abort
()
const
openCleanupDialog
=
()
=>
{
cleanupDialogVisible
.
value
=
true
}
const
getRequestTypeLabel
=
(
log
:
AdminUsageLog
):
string
=>
{
...
...
@@ -443,9 +481,10 @@ const exportToExcel = async () => {
]
const
ws
=
XLSX
.
utils
.
aoa_to_sheet
([
headers
])
while
(
true
)
{
const
requestType
=
filters
.
value
.
request_type
const
legacyStream
=
requestType
?
requestTypeToLegacyStream
(
requestType
)
:
filters
.
value
.
stream
const
res
=
await
adminUsageAPI
.
list
({
page
:
p
,
page_size
:
100
,
exact_total
:
true
,
...
filters
.
value
,
stream
:
legacyStream
===
null
?
undefined
:
legacyStream
},
{
signal
:
c
.
signal
})
const
res
=
await
adminUsageAPI
.
list
(
buildUsageListParams
(
p
,
100
,
true
),
{
signal
:
c
.
signal
}
)
if
(
c
.
signal
.
aborted
)
break
;
if
(
p
===
1
)
{
total
=
res
.
total
;
exportProgress
.
total
=
total
}
const
rows
=
(
res
.
items
||
[]).
map
((
log
:
AdminUsageLog
)
=>
[
log
.
created_at
,
log
.
user
?.
email
||
''
,
log
.
api_key
?.
name
||
''
,
log
.
account
?.
name
||
''
,
log
.
model
,
...
...
frontend/src/views/admin/UsersView.vue
View file @
1ef3782d
...
...
@@ -235,7 +235,17 @@
<!-- Users Table -->
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"users"
:loading=
"loading"
:actions-count=
"7"
>
<DataTable
:columns=
"columns"
:data=
"users"
:loading=
"loading"
:actions-count=
"7"
:server-side-sort=
"true"
default-sort-key=
"created_at"
default-sort-order=
"desc"
:sort-storage-key=
"USER_SORT_STORAGE_KEY"
@
sort=
"handleSort"
>
<template
#cell-email
="
{ value }">
<div
class=
"flex items-center gap-2"
>
<div
...
...
@@ -774,6 +784,25 @@ const columns = computed<Column[]>(() =>
const
users
=
ref
<
AdminUser
[]
>
([])
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
USER_SORT_STORAGE_KEY
=
'
admin-users-table-sort
'
const
loadInitialSortState
=
():
{
sort_by
:
string
;
sort_order
:
'
asc
'
|
'
desc
'
}
=>
{
const
fallback
=
{
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
}
const
sortable
=
new
Set
([
'
email
'
,
'
id
'
,
'
username
'
,
'
role
'
,
'
balance
'
,
'
concurrency
'
,
'
status
'
,
'
created_at
'
])
try
{
const
raw
=
localStorage
.
getItem
(
USER_SORT_STORAGE_KEY
)
if
(
!
raw
)
return
fallback
const
parsed
=
JSON
.
parse
(
raw
)
as
{
key
?:
string
;
order
?:
string
}
const
key
=
typeof
parsed
.
key
===
'
string
'
?
parsed
.
key
:
''
if
(
!
sortable
.
has
(
key
))
return
fallback
return
{
sort_by
:
key
,
sort_order
:
parsed
.
order
===
'
asc
'
?
'
asc
'
:
'
desc
'
}
}
catch
{
return
fallback
}
}
const
sortState
=
reactive
(
loadInitialSortState
())
// Groups data for the groups column
const
allGroups
=
ref
<
AdminGroup
[]
>
([])
...
...
@@ -1125,7 +1154,9 @@ const loadUsers = async () => {
search
:
searchQuery
.
value
||
undefined
,
group_name
:
filters
.
group
||
undefined
,
attributes
:
Object
.
keys
(
attrFilters
).
length
>
0
?
attrFilters
:
undefined
,
include_subscriptions
:
hasVisibleSubscriptionsColumn
.
value
include_subscriptions
:
hasVisibleSubscriptionsColumn
.
value
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
},
{
signal
}
)
...
...
@@ -1184,6 +1215,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsers
()
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadUsers
()
}
// Filter helpers
const
getAttributeDefinitionName
=
(
attrId
:
number
):
string
=>
{
const
def
=
attributeDefinitions
.
value
.
find
(
d
=>
d
.
id
===
attrId
)
...
...
frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
View file @
1ef3782d
...
...
@@ -202,7 +202,6 @@
:total=
"total"
:page=
"page"
:page-size=
"pageSize"
:page-size-options=
"[10]"
@
update:page=
"emit('update:page', $event)"
@
update:pageSize=
"emit('update:pageSize', $event)"
/>
...
...
frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
View file @
1ef3782d
...
...
@@ -512,7 +512,6 @@ onMounted(async () => {
:total=
"total"
:page=
"page"
:page-size=
"pageSize"
:page-size-options=
"[10, 20, 50, 100, 200]"
@
update:page=
"onPageChange"
@
update:page-size=
"onPageSizeChange"
/>
...
...
frontend/src/views/user/KeysView.vue
View file @
1ef3782d
...
...
@@ -49,7 +49,15 @@
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"apiKeys"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"apiKeys"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"created_at"
default-sort-order=
"desc"
@
sort=
"handleSort"
>
<template
#cell-key
="
{ value, row }">
<div
class=
"flex items-center gap-2"
>
<code
class=
"code text-xs"
>
...
...
@@ -1114,6 +1122,10 @@ const pagination = ref({
total
:
0
,
pages
:
0
})
const
sortState
=
ref
({
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
})
// Filter state
const
filterSearch
=
ref
(
''
)
...
...
@@ -1277,10 +1289,18 @@ const loadApiKeys = async () => {
loading
.
value
=
true
try
{
// Build filters
const
filters
:
{
search
?:
string
;
status
?:
string
;
group_id
?:
number
|
string
}
=
{}
const
filters
:
{
search
?:
string
status
?:
string
group_id
?:
number
|
string
sort_by
?:
string
sort_order
?:
'
asc
'
|
'
desc
'
}
=
{}
if
(
filterSearch
.
value
)
filters
.
search
=
filterSearch
.
value
if
(
filterStatus
.
value
)
filters
.
status
=
filterStatus
.
value
if
(
filterGroupId
.
value
!==
''
)
filters
.
group_id
=
filterGroupId
.
value
filters
.
sort_by
=
sortState
.
value
.
sort_by
filters
.
sort_order
=
sortState
.
value
.
sort_order
const
response
=
await
keysAPI
.
list
(
pagination
.
value
.
page
,
pagination
.
value
.
page_size
,
filters
,
{
signal
...
...
@@ -1360,6 +1380,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadApiKeys
()
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
value
.
sort_by
=
key
sortState
.
value
.
sort_order
=
order
pagination
.
value
.
page
=
1
loadApiKeys
()
}
const
editKey
=
(
key
:
ApiKey
)
=>
{
selectedKey
.
value
=
key
const
hasIPRestriction
=
(
key
.
ip_whitelist
?.
length
>
0
)
||
(
key
.
ip_blacklist
?.
length
>
0
)
...
...
frontend/src/views/user/UsageView.vue
View file @
1ef3782d
...
...
@@ -149,7 +149,15 @@
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"usageLogs"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"usageLogs"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"created_at"
default-sort-order=
"desc"
@
sort=
"handleSort"
>
<template
#cell-api_key
="
{ row }">
<span
class=
"text-sm text-gray-900 dark:text-white"
>
{{
row
.
api_key
?.
name
||
'
-
'
...
...
@@ -598,6 +606,10 @@ const pagination = reactive({
total
:
0
,
pages
:
0
})
const
sortState
=
reactive
({
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
})
const
formatDuration
=
(
ms
:
number
):
string
=>
{
if
(
ms
<
1000
)
return
`
${
ms
.
toFixed
(
0
)}
ms`
...
...
@@ -660,6 +672,18 @@ const formatTokens = (value: number): string => {
return
value
.
toLocaleString
()
}
type
UsageTableQueryParams
=
UsageQueryParams
&
{
sort_by
?:
string
sort_order
?:
'
asc
'
|
'
desc
'
}
const
buildUsageQueryParams
=
(
page
:
number
,
pageSize
:
number
):
UsageTableQueryParams
=>
({
page
,
page_size
:
pageSize
,
...
filters
.
value
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
})
const
loadUsageLogs
=
async
()
=>
{
if
(
abortController
)
{
...
...
@@ -670,13 +694,10 @@ const loadUsageLogs = async () => {
const
{
signal
}
=
currentAbortController
loading
.
value
=
true
try
{
const
params
:
UsageQueryParams
=
{
page
:
pagination
.
page
,
page_size
:
pagination
.
page_size
,
...
filters
.
value
}
const
response
=
await
usageAPI
.
query
(
params
,
{
signal
})
const
response
=
await
usageAPI
.
query
(
buildUsageQueryParams
(
pagination
.
page
,
pagination
.
page_size
),
{
signal
}
)
if
(
signal
.
aborted
)
{
return
}
...
...
@@ -758,6 +779,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsageLogs
()
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadUsageLogs
()
}
/**
* Escape CSV value to prevent injection and handle special characters
*/
...
...
@@ -795,12 +823,7 @@ const exportToCSV = async () => {
const
totalRequests
=
Math
.
ceil
(
pagination
.
total
/
pageSize
)
for
(
let
page
=
1
;
page
<=
totalRequests
;
page
++
)
{
const
params
:
UsageQueryParams
=
{
page
:
page
,
page_size
:
pageSize
,
...
filters
.
value
}
const
response
=
await
usageAPI
.
query
(
params
)
const
response
=
await
usageAPI
.
query
(
buildUsageQueryParams
(
page
,
pageSize
))
allLogs
.
push
(...
response
.
items
)
}
...
...
frontend/src/views/user/__tests__/UsageView.spec.ts
View file @
1ef3782d
...
...
@@ -256,6 +256,17 @@ describe('user UsageView tooltip', () => {
await
setupState
.
exportToCSV
()
expect
(
exportedBlob
).
not
.
toBeNull
()
const
hasSortedExportQuery
=
query
.
mock
.
calls
.
some
((
call
)
=>
{
const
params
=
call
[
0
]
as
Record
<
string
,
unknown
>
|
undefined
const
config
=
call
[
1
]
return
(
params
?.
page_size
===
100
&&
params
?.
sort_by
===
'
created_at
'
&&
params
?.
sort_order
===
'
desc
'
&&
config
===
undefined
)
})
expect
(
hasSortedExportQuery
).
toBe
(
true
)
expect
(
clickSpy
).
toHaveBeenCalled
()
expect
(
showSuccess
).
toHaveBeenCalled
()
...
...
Prev
1
2
3
4
5
6
Next
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