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
5f8e60a1
Commit
5f8e60a1
authored
Apr 09, 2026
by
IanShaw027
Browse files
feat(table): 表格排序与搜索改为后端处理
parent
66e15a54
Changes
79
Show whitespace changes
Inline
Side-by-side
frontend/src/components/common/README.md
View file @
5f8e60a1
...
...
@@ -52,7 +52,7 @@ Pagination component with page numbers, navigation, and page size selector.
-
`total: number`
- Total number of items
-
`page: number`
- Current page (1-indexed)
-
`pageSize: number`
- Items per page
-
`pageSizeOptions?: number[]`
- Available page size options (default: [10, 20, 50
, 100
])
-
`pageSizeOptions?: number[]`
- Available page size options (default: [10, 20, 50])
**Events:**
...
...
frontend/src/i18n/locales/en.ts
View file @
5f8e60a1
...
...
@@ -2036,6 +2036,7 @@ export default {
rateLimited
:
'
Rate Limited
'
,
overloaded
:
'
Overloaded
'
,
tempUnschedulable
:
'
Temp Unschedulable
'
,
unschedulable
:
'
Unschedulable
'
,
rateLimitedUntil
:
'
Rate limited and removed from scheduling. Auto resumes at {time}
'
,
rateLimitedAutoResume
:
'
Auto resumes in {time}
'
,
modelRateLimitedUntil
:
'
{model} rate limited until {time}
'
,
...
...
@@ -4287,6 +4288,15 @@ export default {
apiBaseUrlPlaceholder
:
'
https://api.example.com
'
,
apiBaseUrlHint
:
'
Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.
'
,
tablePreferencesTitle
:
'
Global Table Preferences
'
,
tablePreferencesDescription
:
'
Configure default pagination behavior for shared table components
'
,
tableDefaultPageSize
:
'
Default Rows Per Page
'
,
tableDefaultPageSizeHint
:
'
Must be an integer between 5 and 1000
'
,
tablePageSizeOptions
:
'
Rows Per Page Options
'
,
tablePageSizeOptionsPlaceholder
:
'
10, 20, 50
'
,
tablePageSizeOptionsHint
:
'
Use commas to separate integers between 5 and 1000; values are deduplicated and sorted on save
'
,
tableDefaultPageSizeRangeError
:
'
Default rows per page must be between {min} and {max}
'
,
tablePageSizeOptionsFormatError
:
'
Invalid options format. Enter comma-separated integers between {min} and {max}
'
,
customEndpoints
:
{
title
:
'
Custom Endpoints
'
,
description
:
'
Add additional API endpoint URLs for users to quickly copy on the API Keys page
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
5f8e60a1
...
...
@@ -2220,6 +2220,7 @@ export default {
rateLimited
:
'
限流中
'
,
overloaded
:
'
过载中
'
,
tempUnschedulable
:
'
临时不可调度
'
,
unschedulable
:
'
不可调度
'
,
rateLimitedUntil
:
'
限流中,当前不参与调度,预计 {time} 自动恢复
'
,
rateLimitedAutoResume
:
'
{time} 自动恢复
'
,
modelRateLimitedUntil
:
'
{model} 限流至 {time}
'
,
...
...
@@ -4449,6 +4450,15 @@ export default {
apiBaseUrl
:
'
API 端点地址
'
,
apiBaseUrlHint
:
'
用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址
'
,
apiBaseUrlPlaceholder
:
'
https://api.example.com
'
,
tablePreferencesTitle
:
'
通用表格设置
'
,
tablePreferencesDescription
:
'
设置后台与用户侧表格组件的默认分页行为
'
,
tableDefaultPageSize
:
'
默认每页条数
'
,
tableDefaultPageSizeHint
:
'
必须为 5-1000 之间的整数
'
,
tablePageSizeOptions
:
'
可选每页条数列表
'
,
tablePageSizeOptionsPlaceholder
:
'
10, 20, 50
'
,
tablePageSizeOptionsHint
:
'
使用英文逗号分隔,取值范围 5-1000,保存时会自动去重并排序
'
,
tableDefaultPageSizeRangeError
:
'
默认每页条数必须在 {min}-{max} 之间
'
,
tablePageSizeOptionsFormatError
:
'
可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔
'
,
customEndpoints
:
{
title
:
'
自定义端点
'
,
description
:
'
添加额外的 API 端点地址,用户可在「API Keys」页面快速复制
'
,
...
...
frontend/src/types/index.ts
View file @
5f8e60a1
...
...
@@ -106,6 +106,8 @@ export interface PublicSettings {
hide_ccs_import_button
:
boolean
purchase_subscription_enabled
:
boolean
purchase_subscription_url
:
string
table_default_page_size
:
number
table_page_size_options
:
number
[]
custom_menu_items
:
CustomMenuItem
[]
custom_endpoints
:
CustomEndpoint
[]
linuxdo_oauth_enabled
:
boolean
...
...
@@ -1350,6 +1352,8 @@ export interface UsageQueryParams {
billing_type
?:
number
|
null
start_date
?:
string
end_date
?:
string
sort_by
?:
string
sort_order
?:
'
asc
'
|
'
desc
'
}
// ==================== Account Usage Statistics ====================
...
...
frontend/src/views/admin/AccountsView.vue
View file @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -73,7 +73,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
}}
</span>
</
template
>
...
...
@@ -1983,6 +1991,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
...
...
@@ -2297,7 +2309,9 @@ const loadGroups = async () => {
platform
:
(
filters
.
platform
as
GroupPlatform
)
||
undefined
,
status
:
filters
.
status
as
any
,
is_exclusive
:
filters
.
is_exclusive
?
filters
.
is_exclusive
===
'
true
'
:
undefined
,
search
:
searchQuery
.
value
.
trim
()
||
undefined
search
:
searchQuery
.
value
.
trim
()
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
},
{
signal
})
if
(
signal
.
aborted
)
return
groups
.
value
=
response
.
items
...
...
@@ -2381,6 +2395,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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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/SubscriptionsView.vue
View file @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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 @
5f8e60a1
...
...
@@ -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
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