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
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/admin/account/AccountTableFilters.vue
View file @
1ef3782d
...
...
@@ -27,7 +27,7 @@ const updatePrivacyMode = (value: string | number | boolean | null) => { emit('u
const
updateGroup
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
group
:
value
})
}
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}])
const
tOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
},
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
},
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
},
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
},
{
value
:
'
bedrock
'
,
label
:
'
AWS Bedrock
'
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
},
{
value
:
'
rate_limited
'
,
label
:
t
(
'
admin.accounts.status.rateLimited
'
)
},
{
value
:
'
temp_unschedulable
'
,
label
:
t
(
'
admin.accounts.status.tempUnschedulable
'
)
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
},
{
value
:
'
rate_limited
'
,
label
:
t
(
'
admin.accounts.status.rateLimited
'
)
},
{
value
:
'
temp_unschedulable
'
,
label
:
t
(
'
admin.accounts.status.tempUnschedulable
'
)
},
{
value
:
'
unschedulable
'
,
label
:
t
(
'
admin.accounts.status.unschedulable
'
)
}])
const
privacyOpts
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allPrivacyModes
'
)
},
{
value
:
'
__unset__
'
,
label
:
t
(
'
admin.accounts.privacyUnset
'
)
},
...
...
frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts
deleted
100644 → 0
View file @
00c08c57
import
{
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
AccountTableFilters
from
'
../AccountTableFilters.vue
'
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
})
}
})
describe
(
'
AccountTableFilters
'
,
()
=>
{
it
(
'
renders privacy mode options and emits privacy_mode updates
'
,
async
()
=>
{
const
wrapper
=
mount
(
AccountTableFilters
,
{
props
:
{
searchQuery
:
''
,
filters
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
group
:
''
,
privacy_mode
:
''
},
groups
:
[]
},
global
:
{
stubs
:
{
SearchInput
:
{
template
:
'
<div />
'
},
Select
:
{
props
:
[
'
modelValue
'
,
'
options
'
],
emits
:
[
'
update:modelValue
'
,
'
change
'
],
template
:
'
<div class="select-stub" :data-options="JSON.stringify(options)" />
'
}
}
}
})
const
selects
=
wrapper
.
findAll
(
'
.select-stub
'
)
expect
(
selects
).
toHaveLength
(
5
)
const
privacyOptions
=
JSON
.
parse
(
selects
[
3
].
attributes
(
'
data-options
'
))
expect
(
privacyOptions
).
toEqual
([
{
value
:
''
,
label
:
'
admin.accounts.allPrivacyModes
'
},
{
value
:
'
__unset__
'
,
label
:
'
admin.accounts.privacyUnset
'
},
{
value
:
'
training_off
'
,
label
:
'
Privacy
'
},
{
value
:
'
training_set_cf_blocked
'
,
label
:
'
CF
'
},
{
value
:
'
training_set_failed
'
,
label
:
'
Fail
'
}
])
})
})
frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue
View file @
1ef3782d
...
...
@@ -21,7 +21,15 @@
</button>
</div>
<DataTable
:columns=
"columns"
:data=
"items"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"items"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"email"
default-sort-order=
"asc"
@
sort=
"handleSort"
>
<template
#cell-email
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
...
...
@@ -62,7 +70,7 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
,
on
M
ounted
,
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
computed
,
on
Unm
ounted
,
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
...
...
@@ -98,23 +106,54 @@ const pagination = reactive({
pages
:
0
})
const
sortState
=
reactive
({
sort_by
:
'
email
'
,
sort_order
:
'
asc
'
as
'
asc
'
|
'
desc
'
})
const
items
=
ref
<
AnnouncementUserReadStatus
[]
>
([])
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
email
'
,
label
:
t
(
'
common.email
'
)
},
{
key
:
'
username
'
,
label
:
t
(
'
admin.users.columns.username
'
)
},
{
key
:
'
balance
'
,
label
:
t
(
'
common.balance
'
)
},
{
key
:
'
email
'
,
label
:
t
(
'
common.email
'
)
,
sortable
:
true
},
{
key
:
'
username
'
,
label
:
t
(
'
admin.users.columns.username
'
)
,
sortable
:
true
},
{
key
:
'
balance
'
,
label
:
t
(
'
common.balance
'
)
,
sortable
:
true
},
{
key
:
'
eligible
'
,
label
:
t
(
'
admin.announcements.eligible
'
)
},
{
key
:
'
read_at
'
,
label
:
t
(
'
admin.announcements.readAt
'
)
}
])
let
currentController
:
AbortController
|
null
=
null
let
searchDebounceTimer
:
number
|
null
=
null
function
resetDialogState
()
{
loading
.
value
=
false
search
.
value
=
''
items
.
value
=
[]
pagination
.
page
=
1
pagination
.
total
=
0
pagination
.
pages
=
0
sortState
.
sort_by
=
'
email
'
sortState
.
sort_order
=
'
asc
'
}
function
cancelPendingLoad
(
resetState
=
false
)
{
if
(
searchDebounceTimer
)
{
window
.
clearTimeout
(
searchDebounceTimer
)
searchDebounceTimer
=
null
}
currentController
?.
abort
()
currentController
=
null
if
(
resetState
)
{
resetDialogState
()
}
}
async
function
load
()
{
if
(
!
props
.
show
||
!
props
.
announcementId
)
return
if
(
currentController
)
currentController
.
abort
()
currentController
=
new
AbortController
()
currentController
?.
abort
()
const
requestController
=
new
AbortController
()
currentController
=
requestController
const
{
signal
}
=
requestController
try
{
loading
.
value
=
true
...
...
@@ -122,20 +161,37 @@ async function load() {
props
.
announcementId
,
pagination
.
page
,
pagination
.
page_size
,
search
.
value
{
search
:
search
.
value
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
},
{
signal
}
)
if
(
signal
.
aborted
||
currentController
!==
requestController
)
return
items
.
value
=
res
.
items
pagination
.
total
=
res
.
total
pagination
.
pages
=
res
.
pages
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
(
'
Failed to load read status:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.announcements.failedToLoadReadStatus
'
))
}
finally
{
loading
.
value
=
false
if
(
currentController
===
requestController
)
{
loading
.
value
=
false
currentController
=
null
}
}
}
...
...
@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
load
()
}
let
searchDebounceTimer
:
number
|
null
=
null
function
handleSort
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
load
()
}
function
handleSearch
()
{
if
(
searchDebounceTimer
)
window
.
clearTimeout
(
searchDebounceTimer
)
searchDebounceTimer
=
window
.
setTimeout
(()
=>
{
...
...
@@ -160,13 +222,17 @@ function handleSearch() {
}
function
handleClose
()
{
cancelPendingLoad
(
true
)
emit
(
'
close
'
)
}
watch
(
()
=>
props
.
show
,
(
v
)
=>
{
if
(
!
v
)
return
if
(
!
v
)
{
cancelPendingLoad
(
true
)
return
}
pagination
.
page
=
1
load
()
}
...
...
@@ -181,7 +247,7 @@ watch(
}
)
on
M
ounted
(()
=>
{
// noop
on
Unm
ounted
(()
=>
{
cancelPendingLoad
()
})
</
script
>
frontend/src/components/admin/announcements/__tests__/AnnouncementReadStatusDialog.spec.ts
0 → 100644
View file @
1ef3782d
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
AnnouncementReadStatusDialog
from
'
../AnnouncementReadStatusDialog.vue
'
const
{
getReadStatus
,
showError
}
=
vi
.
hoisted
(()
=>
({
getReadStatus
:
vi
.
fn
(),
showError
:
vi
.
fn
(),
}))
vi
.
mock
(
'
@/api/admin
'
,
()
=>
({
adminAPI
:
{
announcements
:
{
getReadStatus
,
},
},
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showError
,
}),
}))
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
,
}),
}
})
vi
.
mock
(
'
@/composables/usePersistedPageSize
'
,
()
=>
({
getPersistedPageSize
:
()
=>
20
,
}))
const
BaseDialogStub
=
{
props
:
[
'
show
'
,
'
title
'
,
'
width
'
],
emits
:
[
'
close
'
],
template
:
'
<div><slot /><slot name="footer" /></div>
'
,
}
describe
(
'
AnnouncementReadStatusDialog
'
,
()
=>
{
beforeEach
(()
=>
{
getReadStatus
.
mockReset
()
showError
.
mockReset
()
vi
.
useFakeTimers
()
})
it
(
'
closes by aborting active requests and clearing debounced reloads
'
,
async
()
=>
{
let
activeSignal
:
AbortSignal
|
undefined
getReadStatus
.
mockImplementation
(
async
(...
args
:
any
[])
=>
{
activeSignal
=
args
[
4
]?.
signal
return
new
Promise
(()
=>
{})
})
const
wrapper
=
mount
(
AnnouncementReadStatusDialog
,
{
props
:
{
show
:
false
,
announcementId
:
1
,
},
global
:
{
stubs
:
{
BaseDialog
:
BaseDialogStub
,
DataTable
:
true
,
Pagination
:
true
,
Icon
:
true
,
},
},
})
await
wrapper
.
setProps
({
show
:
true
})
await
flushPromises
()
expect
(
getReadStatus
).
toHaveBeenCalledTimes
(
1
)
expect
(
activeSignal
?.
aborted
).
toBe
(
false
)
const
setupState
=
(
wrapper
.
vm
as
any
).
$
?.
setupState
setupState
.
search
=
'
alice
'
setupState
.
handleSearch
()
setupState
.
handleClose
()
await
flushPromises
()
expect
(
activeSignal
?.
aborted
).
toBe
(
true
)
expect
(
wrapper
.
emitted
(
'
close
'
)).
toHaveLength
(
1
)
vi
.
advanceTimersByTime
(
350
)
await
flushPromises
()
expect
(
getReadStatus
).
toHaveBeenCalledTimes
(
1
)
})
})
frontend/src/components/admin/group/GroupRateMultipliersModal.vue
View file @
1ef3782d
...
...
@@ -196,7 +196,6 @@
:total=
"localEntries.length"
:page=
"currentPage"
:page-size=
"pageSize"
:page-size-options=
"[10, 20, 50]"
@
update:page=
"currentPage = $event"
@
update:pageSize=
"handlePageSizeChange"
/>
...
...
frontend/src/components/admin/usage/UsageTable.vue
View file @
1ef3782d
<
template
>
<div
class=
"card overflow-hidden"
>
<div
class=
"overflow-auto"
>
<DataTable
:columns=
"columns"
:data=
"data"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"data"
:loading=
"loading"
:server-side-sort=
"serverSideSort"
:default-sort-key=
"defaultSortKey"
:default-sort-order=
"defaultSortOrder"
@
sort=
"(key, order) => $emit('sort', key, order)"
>
<template
#cell-user
="
{ row }">
<div
class=
"text-sm"
>
<button
...
...
@@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
AdminUsageLog
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
interface
Props
{
data
:
AdminUsageLog
[]
loading
?:
boolean
columns
:
Column
[]
serverSideSort
?:
boolean
defaultSortKey
?:
string
defaultSortOrder
?:
'
asc
'
|
'
desc
'
}
defineProps
([
'
data
'
,
'
loading
'
,
'
columns
'
])
defineEmits
([
'
userClick
'
])
withDefaults
(
defineProps
<
Props
>
(),
{
loading
:
false
,
serverSideSort
:
false
,
defaultSortKey
:
''
,
defaultSortOrder
:
'
asc
'
})
defineEmits
<
{
userClick
:
[
userID
:
number
,
email
?:
string
]
sort
:
[
key
:
string
,
order
:
'
asc
'
|
'
desc
'
]
}
>
()
const
{
t
}
=
useI18n
()
// Tooltip state - cost
...
...
frontend/src/components/common/DataTable.vue
View file @
1ef3782d
...
...
@@ -68,7 +68,7 @@
'is-scrollable': isScrollable
}"
>
<table
class=
"
min-
w-full divide-y divide-gray-200 dark:divide-dark-700"
>
<table
class=
"w-full
min-w-max
divide-y divide-gray-200 dark:divide-dark-700"
>
<thead
class=
"table-header bg-gray-50 dark:bg-dark-800"
>
<tr>
<th
...
...
@@ -797,3 +797,62 @@ tbody tr:hover .sticky-col {
background
:
linear-gradient
(
to
left
,
rgba
(
0
,
0
,
0
,
0.2
),
transparent
);
}
</
style
>
<
style
>
/* ==========================================================================
终极悬浮滚动条防丢器 (Sledgehammer Override)
绕过 style.css 中 `* { scrollbar-color: transparent }` 的全局悬停隐身诅咒!
========================================================================== */
/* 1. 废除全局针对所有元素的 scrollbar-width 设定,拿回 Chrome/Safari 下 Webkit 滚动条规则的控制权! */
.table-wrapper
{
scrollbar-width
:
auto
!important
;
/* 阻止 Chrome 121 退化到原生 Mac 闪隐滚动条 */
}
/* 2. 重写 Webkit 滚动层,全部加上 !important 强制覆盖透明悬停陷阱 */
.table-wrapper
::-webkit-scrollbar
{
height
:
12px
!important
;
width
:
12px
!important
;
display
:
block
!important
;
background-color
:
transparent
!important
;
}
.table-wrapper
::-webkit-scrollbar-track
{
background-color
:
rgba
(
0
,
0
,
0
,
0.03
)
!important
;
border-radius
:
6px
!important
;
margin
:
0
4px
!important
;
}
.dark
.table-wrapper
::-webkit-scrollbar-track
{
background-color
:
rgba
(
255
,
255
,
255
,
0.05
)
!important
;
}
/* 常驻、不透明的滑块,无视鼠标是否 hover 都在那! */
.table-wrapper
::-webkit-scrollbar-thumb
{
background-color
:
rgba
(
107
,
114
,
128
,
0.75
)
!important
;
border-radius
:
6px
!important
;
border
:
2px
solid
transparent
!important
;
background-clip
:
padding-box
!important
;
-webkit-appearance
:
none
!important
;
}
.table-wrapper
::-webkit-scrollbar-thumb:hover
{
background-color
:
rgba
(
75
,
85
,
99
,
0.9
)
!important
;
}
.dark
.table-wrapper
::-webkit-scrollbar-thumb
{
background-color
:
rgba
(
156
,
163
,
175
,
0.75
)
!important
;
}
.dark
.table-wrapper
::-webkit-scrollbar-thumb:hover
{
background-color
:
rgba
(
209
,
213
,
219
,
0.9
)
!important
;
}
/* 3. 仅给真正的 Firefox 留的后路 */
@supports
(
-moz-appearance
:
none
)
{
.table-wrapper
{
scrollbar-width
:
thin
!important
;
scrollbar-color
:
rgba
(
156
,
163
,
175
,
0.5
)
rgba
(
0
,
0
,
0
,
0.03
)
!important
;
}
.dark
.table-wrapper
{
scrollbar-color
:
rgba
(
75
,
85
,
99
,
0.5
)
rgba
(
255
,
255
,
255
,
0.05
)
!important
;
}
}
</
style
>
frontend/src/components/common/Pagination.vue
View file @
1ef3782d
...
...
@@ -122,7 +122,7 @@ import { computed, ref } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Select
from
'
./Select.vue
'
import
{
s
et
PersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
import
{
g
et
ConfiguredTablePageSizeOptions
,
normalizeTablePageSize
}
from
'
@/utils/tablePreferences
'
const
{
t
}
=
useI18n
()
...
...
@@ -141,7 +141,7 @@ interface Emits {
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
pageSizeOptions
:
()
=>
[
10
,
20
,
50
,
100
]
,
pageSizeOptions
:
()
=>
getConfiguredTablePageSizeOptions
()
,
showPageSizeSelector
:
true
,
showJump
:
false
}
)
...
...
@@ -161,7 +161,14 @@ const toItem = computed(() => {
}
)
const
pageSizeSelectOptions
=
computed
(()
=>
{
return
props
.
pageSizeOptions
.
map
((
size
)
=>
({
const
options
=
Array
.
from
(
new
Set
([
...
getConfiguredTablePageSizeOptions
(),
normalizeTablePageSize
(
props
.
pageSize
)
])
).
sort
((
a
,
b
)
=>
a
-
b
)
return
options
.
map
((
size
)
=>
({
value
:
size
,
label
:
String
(
size
)
}
))
...
...
@@ -216,8 +223,7 @@ const goToPage = (newPage: number) => {
const
handlePageSizeChange
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
if
(
value
===
null
||
typeof
value
===
'
boolean
'
)
return
const
newPageSize
=
typeof
value
===
'
string
'
?
parseInt
(
value
)
:
value
setPersistedPageSize
(
newPageSize
)
const
newPageSize
=
normalizeTablePageSize
(
typeof
value
===
'
string
'
?
parseInt
(
value
,
10
)
:
value
)
emit
(
'
update:pageSize
'
,
newPageSize
)
}
...
...
frontend/src/components/layout/AppSidebar.vue
View file @
1ef3782d
...
...
@@ -669,11 +669,14 @@ onMounted(() => {
opacity
:
0
;
}
/* Custom SVG icon in sidebar: inherit color, constrain size */
/* Custom SVG icon in sidebar: constrain size without overriding uploaded SVG colors */
.sidebar-svg-icon
{
color
:
currentColor
;
}
.sidebar-svg-icon
:deep
(
svg
)
{
display
:
block
;
width
:
1.25rem
;
height
:
1.25rem
;
stroke
:
currentColor
;
fill
:
none
;
}
</
style
>
frontend/src/components/layout/__tests__/AppSidebar.spec.ts
0 → 100644
View file @
1ef3782d
import
{
readFileSync
}
from
'
node:fs
'
import
{
dirname
,
resolve
}
from
'
node:path
'
import
{
fileURLToPath
}
from
'
node:url
'
import
{
describe
,
expect
,
it
}
from
'
vitest
'
const
componentPath
=
resolve
(
dirname
(
fileURLToPath
(
import
.
meta
.
url
)),
'
../AppSidebar.vue
'
)
const
componentSource
=
readFileSync
(
componentPath
,
'
utf8
'
)
describe
(
'
AppSidebar custom SVG styles
'
,
()
=>
{
it
(
'
does not override uploaded SVG fill or stroke colors
'
,
()
=>
{
expect
(
componentSource
).
toContain
(
'
.sidebar-svg-icon {
'
)
expect
(
componentSource
).
toContain
(
'
color: currentColor;
'
)
expect
(
componentSource
).
toContain
(
'
display: block;
'
)
expect
(
componentSource
).
not
.
toContain
(
'
stroke: currentColor;
'
)
expect
(
componentSource
).
not
.
toContain
(
'
fill: none;
'
)
})
})
frontend/src/composables/__tests__/usePersistedPageSize.spec.ts
0 → 100644
View file @
1ef3782d
import
{
afterEach
,
describe
,
expect
,
it
}
from
'
vitest
'
import
{
getPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
describe
(
'
usePersistedPageSize
'
,
()
=>
{
afterEach
(()
=>
{
localStorage
.
clear
()
delete
window
.
__APP_CONFIG__
})
it
(
'
uses the system table default instead of stale localStorage state
'
,
()
=>
{
window
.
__APP_CONFIG__
=
{
table_default_page_size
:
1000
,
table_page_size_options
:
[
20
,
50
,
1000
]
}
as
any
localStorage
.
setItem
(
'
table-page-size
'
,
'
50
'
)
localStorage
.
setItem
(
'
table-page-size-source
'
,
'
user
'
)
expect
(
getPersistedPageSize
()).
toBe
(
1000
)
})
})
frontend/src/composables/usePersistedPageSize.ts
View file @
1ef3782d
const
STORAGE_KEY
=
'
table-page-size
'
const
DEFAULT_PAGE_SIZE
=
20
import
{
getConfiguredTableDefaultPageSize
,
normalizeTablePageSize
}
from
'
@/utils/tablePreferences
'
/**
*
从 localStorage 读取/写入 pageSize
*
全局共享一个 key,所有表格统一偏好
*
读取当前系统配置的表格默认每页条数。
*
不再使用本地持久化缓存,所有页面统一以通用表格设置为准。
*/
export
function
getPersistedPageSize
(
fallback
=
DEFAULT_PAGE_SIZE
):
number
{
try
{
const
stored
=
localStorage
.
getItem
(
STORAGE_KEY
)
if
(
stored
)
{
const
parsed
=
Number
(
stored
)
if
(
Number
.
isFinite
(
parsed
)
&&
parsed
>
0
)
return
parsed
}
}
catch
{
// localStorage 不可用(隐私模式等)
}
return
fallback
}
export
function
setPersistedPageSize
(
size
:
number
):
void
{
try
{
localStorage
.
setItem
(
STORAGE_KEY
,
String
(
size
))
}
catch
{
// 静默失败
}
export
function
getPersistedPageSize
(
fallback
=
getConfiguredTableDefaultPageSize
()):
number
{
return
normalizeTablePageSize
(
getConfiguredTableDefaultPageSize
()
||
fallback
)
}
frontend/src/composables/useTableLoader.ts
View file @
1ef3782d
import
{
ref
,
reactive
,
onUnmounted
,
toRaw
}
from
'
vue
'
import
{
useDebounceFn
}
from
'
@vueuse/core
'
import
type
{
BasePaginationResponse
,
FetchOptions
}
from
'
@/types
'
import
{
getPersistedPageSize
,
setPersistedPageSize
}
from
'
./usePersistedPageSize
'
import
{
getPersistedPageSize
}
from
'
./usePersistedPageSize
'
interface
PaginationState
{
page
:
number
...
...
@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const
handlePageSizeChange
=
(
size
:
number
)
=>
{
pagination
.
page_size
=
size
pagination
.
page
=
1
setPersistedPageSize
(
size
)
load
()
}
...
...
frontend/src/i18n/locales/en.ts
View file @
1ef3782d
...
...
@@ -2051,6 +2051,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}
'
,
...
...
@@ -4353,6 +4354,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, 100
'
,
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 @
1ef3782d
...
...
@@ -2234,6 +2234,7 @@ export default {
rateLimited
:
'
限流中
'
,
overloaded
:
'
过载中
'
,
tempUnschedulable
:
'
临时不可调度
'
,
unschedulable
:
'
不可调度
'
,
rateLimitedUntil
:
'
限流中,当前不参与调度,预计 {time} 自动恢复
'
,
rateLimitedAutoResume
:
'
{time} 自动恢复
'
,
modelRateLimitedUntil
:
'
{model} 限流至 {time}
'
,
...
...
@@ -4514,6 +4515,15 @@ export default {
apiBaseUrl
:
'
API 端点地址
'
,
apiBaseUrlHint
:
'
用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址
'
,
apiBaseUrlPlaceholder
:
'
https://api.example.com
'
,
tablePreferencesTitle
:
'
通用表格设置
'
,
tablePreferencesDescription
:
'
设置后台与用户侧表格组件的默认分页行为
'
,
tableDefaultPageSize
:
'
默认每页条数
'
,
tableDefaultPageSizeHint
:
'
必须为 5-1000 之间的整数
'
,
tablePageSizeOptions
:
'
可选每页条数列表
'
,
tablePageSizeOptionsPlaceholder
:
'
10, 20, 50, 100
'
,
tablePageSizeOptionsHint
:
'
使用英文逗号分隔,取值范围 5-1000,保存时会自动去重并排序
'
,
tableDefaultPageSizeRangeError
:
'
默认每页条数必须在 {min}-{max} 之间
'
,
tablePageSizeOptionsFormatError
:
'
可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔
'
,
customEndpoints
:
{
title
:
'
自定义端点
'
,
description
:
'
添加额外的 API 端点地址,用户可在「API Keys」页面快速复制
'
,
...
...
frontend/src/stores/__tests__/app.spec.ts
View file @
1ef3782d
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
,
afterEach
}
from
'
vitest
'
import
{
setActivePinia
,
createPinia
}
from
'
pinia
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
getPublicSettings
}
from
'
@/api/auth
'
// Mock API 模块
vi
.
mock
(
'
@/api/admin/system
'
,
()
=>
({
...
...
@@ -15,12 +16,14 @@ describe('useAppStore', () => {
beforeEach
(()
=>
{
setActivePinia
(
createPinia
())
vi
.
useFakeTimers
()
localStorage
.
clear
()
// 清除 window.__APP_CONFIG__
delete
(
window
as
any
).
__APP_CONFIG__
})
afterEach
(()
=>
{
vi
.
useRealTimers
()
localStorage
.
clear
()
})
// --- Toast 消息管理 ---
...
...
@@ -291,5 +294,43 @@ describe('useAppStore', () => {
expect
(
store
.
publicSettingsLoaded
).
toBe
(
false
)
expect
(
store
.
cachedPublicSettings
).
toBeNull
()
})
it
(
'
fetchPublicSettings(force) 会同步更新运行时注入配置
'
,
async
()
=>
{
vi
.
mocked
(
getPublicSettings
).
mockResolvedValue
({
registration_enabled
:
false
,
email_verify_enabled
:
false
,
registration_email_suffix_whitelist
:
[],
promo_code_enabled
:
true
,
password_reset_enabled
:
false
,
invitation_code_enabled
:
false
,
turnstile_enabled
:
false
,
turnstile_site_key
:
''
,
site_name
:
'
Updated Site
'
,
site_logo
:
''
,
site_subtitle
:
''
,
api_base_url
:
''
,
contact_info
:
''
,
doc_url
:
''
,
home_content
:
''
,
hide_ccs_import_button
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
table_default_page_size
:
1000
,
table_page_size_options
:
[
20
,
100
,
1000
],
custom_menu_items
:
[],
custom_endpoints
:
[],
linuxdo_oauth_enabled
:
false
,
backend_mode_enabled
:
false
,
version
:
'
1.0.0
'
})
const
store
=
useAppStore
()
await
store
.
fetchPublicSettings
(
true
)
expect
((
window
as
any
).
__APP_CONFIG__
.
table_default_page_size
).
toBe
(
1000
)
expect
((
window
as
any
).
__APP_CONFIG__
.
table_page_size_options
).
toEqual
([
20
,
100
,
1000
])
expect
(
localStorage
.
getItem
(
'
table-page-size
'
)).
toBeNull
()
expect
(
localStorage
.
getItem
(
'
table-page-size-source
'
)).
toBeNull
()
})
})
})
frontend/src/stores/app.ts
View file @
1ef3782d
...
...
@@ -284,6 +284,9 @@ export const useAppStore = defineStore('app', () => {
* Apply settings to store state (internal helper to avoid code duplication)
*/
function
applySettings
(
config
:
PublicSettings
):
void
{
if
(
typeof
window
!==
'
undefined
'
)
{
window
.
__APP_CONFIG__
=
{
...
config
}
}
cachedPublicSettings
.
value
=
config
siteName
.
value
=
config
.
site_name
||
'
Sub2API
'
siteLogo
.
value
=
config
.
site_logo
||
''
...
...
@@ -329,6 +332,8 @@ export const useAppStore = defineStore('app', () => {
hide_ccs_import_button
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
table_default_page_size
:
20
,
table_page_size_options
:
[
10
,
20
,
50
,
100
],
custom_menu_items
:
[],
custom_endpoints
:
[],
linuxdo_oauth_enabled
:
false
,
...
...
frontend/src/style.css
View file @
1ef3782d
...
...
@@ -16,20 +16,22 @@
@apply
min-h-screen;
}
/* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */
*
{
scrollbar-width
:
thin
;
scrollbar-color
:
transparent
transparent
;
}
/* 自定义滚动条 - 仅针对 Firefox,避免 Chrome 取消 webkit 的全局定制 */
@supports
(
-moz-appearance
:
none
)
{
*
{
scrollbar-width
:
thin
;
scrollbar-color
:
transparent
transparent
;
}
*
:hover
,
*
:focus-within
{
scrollbar-color
:
rgba
(
156
,
163
,
175
,
0.5
)
transparent
;
}
*
:hover
,
*
:focus-within
{
scrollbar-color
:
rgba
(
156
,
163
,
175
,
0.5
)
transparent
;
}
.dark
*
:hover
,
.dark
*
:focus-within
{
scrollbar-color
:
rgba
(
75
,
85
,
99
,
0.5
)
transparent
;
.dark
*
:hover
,
.dark
*
:focus-within
{
scrollbar-color
:
rgba
(
75
,
85
,
99
,
0.5
)
transparent
;
}
}
::-webkit-scrollbar
{
...
...
@@ -58,36 +60,7 @@
@apply
bg-primary-500/20
text-primary-900
dark
:
text-primary-100
;
}
/*
* 表格滚动容器:始终显示滚动条,不跟随全局悬停策略。
*
* 浏览器兼容性说明:
* - Chrome 121+ 原生支持 scrollbar-color / scrollbar-width。
* 一旦元素匹配了这两个标准属性,::-webkit-scrollbar-* 被完全忽略。
* 全局 * { scrollbar-width: thin } 使所有元素都走标准属性,
* 因此 Chrome 121+ 只看 scrollbar-color。
* - Chrome < 121 不认识标准属性,只看 ::-webkit-scrollbar-*,
* 所以保留 ::-webkit-scrollbar-thumb 作为回退。
* - Firefox 始终只看 scrollbar-color / scrollbar-width。
*/
.table-wrapper
{
scrollbar-width
:
auto
;
scrollbar-color
:
rgba
(
156
,
163
,
175
,
0.7
)
transparent
;
}
.dark
.table-wrapper
{
scrollbar-color
:
rgba
(
75
,
85
,
99
,
0.7
)
transparent
;
}
/* 旧版 Chrome (< 121) 兼容回退 */
.table-wrapper
::-webkit-scrollbar
{
width
:
10px
;
height
:
10px
;
}
.table-wrapper
::-webkit-scrollbar-thumb
{
@apply
rounded-full
bg-gray-400/70;
}
.dark
.table-wrapper
::-webkit-scrollbar-thumb
{
@apply
rounded-full
bg-gray-500/70;
}
}
@layer
components
{
...
...
frontend/src/types/index.ts
View file @
1ef3782d
...
...
@@ -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
...
...
@@ -1363,6 +1365,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/utils/__tests__/tablePreferences.spec.ts
0 → 100644
View file @
1ef3782d
import
{
afterEach
,
describe
,
expect
,
it
}
from
'
vitest
'
import
{
DEFAULT_TABLE_PAGE_SIZE
,
DEFAULT_TABLE_PAGE_SIZE_OPTIONS
,
getConfiguredTableDefaultPageSize
,
getConfiguredTablePageSizeOptions
,
normalizeTablePageSize
}
from
'
@/utils/tablePreferences
'
describe
(
'
tablePreferences
'
,
()
=>
{
afterEach
(()
=>
{
delete
window
.
__APP_CONFIG__
})
it
(
'
returns built-in defaults when app config is missing
'
,
()
=>
{
expect
(
getConfiguredTableDefaultPageSize
()).
toBe
(
DEFAULT_TABLE_PAGE_SIZE
)
expect
(
getConfiguredTablePageSizeOptions
()).
toEqual
(
DEFAULT_TABLE_PAGE_SIZE_OPTIONS
)
})
it
(
'
uses configured defaults when app config is valid
'
,
()
=>
{
window
.
__APP_CONFIG__
=
{
table_default_page_size
:
50
,
table_page_size_options
:
[
20
,
50
,
100
]
}
as
any
expect
(
getConfiguredTableDefaultPageSize
()).
toBe
(
50
)
expect
(
getConfiguredTablePageSizeOptions
()).
toEqual
([
20
,
50
,
100
])
})
it
(
'
allows default page size outside selectable options
'
,
()
=>
{
window
.
__APP_CONFIG__
=
{
table_default_page_size
:
1000
,
table_page_size_options
:
[
20
,
50
,
100
]
}
as
any
expect
(
getConfiguredTableDefaultPageSize
()).
toBe
(
1000
)
expect
(
getConfiguredTablePageSizeOptions
()).
toEqual
([
20
,
50
,
100
])
expect
(
normalizeTablePageSize
(
1000
)).
toBe
(
100
)
expect
(
normalizeTablePageSize
(
35
)).
toBe
(
50
)
})
it
(
'
normalizes invalid options without rewriting the configured default itself
'
,
()
=>
{
window
.
__APP_CONFIG__
=
{
table_default_page_size
:
35
,
table_page_size_options
:
[
1001
,
50
,
10
,
10
,
2
,
0
]
}
as
any
expect
(
getConfiguredTableDefaultPageSize
()).
toBe
(
35
)
expect
(
getConfiguredTablePageSizeOptions
()).
toEqual
([
10
,
50
])
expect
(
normalizeTablePageSize
(
undefined
)).
toBe
(
50
)
})
it
(
'
normalizes page size against configured options by rounding up
'
,
()
=>
{
window
.
__APP_CONFIG__
=
{
table_default_page_size
:
20
,
table_page_size_options
:
[
20
,
50
,
1000
]
}
as
any
expect
(
normalizeTablePageSize
(
20
)).
toBe
(
20
)
expect
(
normalizeTablePageSize
(
30
)).
toBe
(
50
)
expect
(
normalizeTablePageSize
(
100
)).
toBe
(
1000
)
expect
(
normalizeTablePageSize
(
1500
)).
toBe
(
1000
)
expect
(
normalizeTablePageSize
(
undefined
)).
toBe
(
20
)
})
it
(
'
keeps built-in selectable defaults at 10, 20, 50, 100
'
,
()
=>
{
window
.
__APP_CONFIG__
=
{
table_default_page_size
:
1000
}
as
any
expect
(
getConfiguredTablePageSizeOptions
()).
toEqual
([
10
,
20
,
50
,
100
])
})
})
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