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
ad80606a
Commit
ad80606a
authored
Apr 09, 2026
by
IanShaw027
Browse files
feat(settings): 增加全局表格分页配置,支持自定义
parent
d8fa38d5
Changes
14
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/setting_handler.go
View file @
ad80606a
...
...
@@ -106,6 +106,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
TableDefaultPageSize
:
settings
.
TableDefaultPageSize
,
TablePageSizeOptions
:
settings
.
TablePageSizeOptions
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
settings
.
CustomMenuItems
),
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
settings
.
CustomEndpoints
),
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
...
...
@@ -175,6 +177,8 @@ type UpdateSettingsRequest struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
*
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
*
string
`json:"purchase_subscription_url"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
*
[]
dto
.
CustomMenuItem
`json:"custom_menu_items"`
CustomEndpoints
*
[]
dto
.
CustomEndpoint
`json:"custom_endpoints"`
...
...
@@ -237,6 +241,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if
req
.
DefaultBalance
<
0
{
req
.
DefaultBalance
=
0
}
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
if
req
.
TableDefaultPageSize
<=
0
{
req
.
TableDefaultPageSize
=
previousSettings
.
TableDefaultPageSize
}
if
req
.
TablePageSizeOptions
==
nil
{
req
.
TablePageSizeOptions
=
previousSettings
.
TablePageSizeOptions
}
req
.
SMTPHost
=
strings
.
TrimSpace
(
req
.
SMTPHost
)
req
.
SMTPUsername
=
strings
.
TrimSpace
(
req
.
SMTPUsername
)
req
.
SMTPPassword
=
strings
.
TrimSpace
(
req
.
SMTPPassword
)
...
...
@@ -564,6 +575,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
HideCcsImportButton
:
req
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
purchaseEnabled
,
PurchaseSubscriptionURL
:
purchaseURL
,
TableDefaultPageSize
:
req
.
TableDefaultPageSize
,
TablePageSizeOptions
:
req
.
TablePageSizeOptions
,
CustomMenuItems
:
customMenuJSON
,
CustomEndpoints
:
customEndpointsJSON
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
...
...
@@ -679,6 +692,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
HideCcsImportButton
:
updatedSettings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
updatedSettings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
updatedSettings
.
PurchaseSubscriptionURL
,
TableDefaultPageSize
:
updatedSettings
.
TableDefaultPageSize
,
TablePageSizeOptions
:
updatedSettings
.
TablePageSizeOptions
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
updatedSettings
.
CustomMenuItems
),
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
updatedSettings
.
CustomEndpoints
),
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
...
...
@@ -871,6 +886,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
PurchaseSubscriptionURL
!=
after
.
PurchaseSubscriptionURL
{
changed
=
append
(
changed
,
"purchase_subscription_url"
)
}
if
before
.
TableDefaultPageSize
!=
after
.
TableDefaultPageSize
{
changed
=
append
(
changed
,
"table_default_page_size"
)
}
if
!
equalIntSlice
(
before
.
TablePageSizeOptions
,
after
.
TablePageSizeOptions
)
{
changed
=
append
(
changed
,
"table_page_size_options"
)
}
if
before
.
CustomMenuItems
!=
after
.
CustomMenuItems
{
changed
=
append
(
changed
,
"custom_menu_items"
)
}
...
...
@@ -927,6 +948,18 @@ func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
return
true
}
func
equalIntSlice
(
a
,
b
[]
int
)
bool
{
if
len
(
a
)
!=
len
(
b
)
{
return
false
}
for
i
:=
range
a
{
if
a
[
i
]
!=
b
[
i
]
{
return
false
}
}
return
true
}
// TestSMTPRequest 测试SMTP连接请求
type
TestSMTPRequest
struct
{
SMTPHost
string
`json:"smtp_host"`
...
...
backend/internal/handler/dto/settings.go
View file @
ad80606a
...
...
@@ -61,6 +61,8 @@ type SystemSettings struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
CustomEndpoints
[]
CustomEndpoint
`json:"custom_endpoints"`
...
...
@@ -125,6 +127,8 @@ type PublicSettings struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
CustomEndpoints
[]
CustomEndpoint
`json:"custom_endpoints"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
...
...
backend/internal/service/setting_service.go
View file @
ad80606a
...
...
@@ -9,6 +9,7 @@ import (
"fmt"
"log/slog"
"net/url"
"sort"
"strconv"
"strings"
"sync/atomic"
...
...
@@ -160,6 +161,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyHideCcsImportButton
,
SettingKeyPurchaseSubscriptionEnabled
,
SettingKeyPurchaseSubscriptionURL
,
SettingKeyTableDefaultPageSize
,
SettingKeyTablePageSizeOptions
,
SettingKeyCustomMenuItems
,
SettingKeyCustomEndpoints
,
SettingKeyLinuxDoConnectEnabled
,
...
...
@@ -184,6 +187,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
registrationEmailSuffixWhitelist
:=
ParseRegistrationEmailSuffixWhitelist
(
settings
[
SettingKeyRegistrationEmailSuffixWhitelist
],
)
tableDefaultPageSize
,
tablePageSizeOptions
:=
parseTablePreferences
(
settings
[
SettingKeyTableDefaultPageSize
],
settings
[
SettingKeyTablePageSizeOptions
],
)
return
&
PublicSettings
{
RegistrationEnabled
:
settings
[
SettingKeyRegistrationEnabled
]
==
"true"
,
...
...
@@ -205,6 +212,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
HideCcsImportButton
:
settings
[
SettingKeyHideCcsImportButton
]
==
"true"
,
PurchaseSubscriptionEnabled
:
settings
[
SettingKeyPurchaseSubscriptionEnabled
]
==
"true"
,
PurchaseSubscriptionURL
:
strings
.
TrimSpace
(
settings
[
SettingKeyPurchaseSubscriptionURL
]),
TableDefaultPageSize
:
tableDefaultPageSize
,
TablePageSizeOptions
:
tablePageSizeOptions
,
CustomMenuItems
:
settings
[
SettingKeyCustomMenuItems
],
CustomEndpoints
:
settings
[
SettingKeyCustomEndpoints
],
LinuxDoOAuthEnabled
:
linuxDoEnabled
,
...
...
@@ -252,6 +261,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url,omitempty"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
json
.
RawMessage
`json:"custom_menu_items"`
CustomEndpoints
json
.
RawMessage
`json:"custom_endpoints"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
...
...
@@ -277,6 +288,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
TableDefaultPageSize
:
settings
.
TableDefaultPageSize
,
TablePageSizeOptions
:
settings
.
TablePageSizeOptions
,
CustomMenuItems
:
filterUserVisibleMenuItems
(
settings
.
CustomMenuItems
),
CustomEndpoints
:
safeRawJSONArray
(
settings
.
CustomEndpoints
),
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
...
...
@@ -471,6 +484,16 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates
[
SettingKeyHideCcsImportButton
]
=
strconv
.
FormatBool
(
settings
.
HideCcsImportButton
)
updates
[
SettingKeyPurchaseSubscriptionEnabled
]
=
strconv
.
FormatBool
(
settings
.
PurchaseSubscriptionEnabled
)
updates
[
SettingKeyPurchaseSubscriptionURL
]
=
strings
.
TrimSpace
(
settings
.
PurchaseSubscriptionURL
)
tableDefaultPageSize
,
tablePageSizeOptions
:=
normalizeTablePreferences
(
settings
.
TableDefaultPageSize
,
settings
.
TablePageSizeOptions
,
)
updates
[
SettingKeyTableDefaultPageSize
]
=
strconv
.
Itoa
(
tableDefaultPageSize
)
tablePageSizeOptionsJSON
,
err
:=
json
.
Marshal
(
tablePageSizeOptions
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"marshal table page size options: %w"
,
err
)
}
updates
[
SettingKeyTablePageSizeOptions
]
=
string
(
tablePageSizeOptionsJSON
)
updates
[
SettingKeyCustomMenuItems
]
=
settings
.
CustomMenuItems
updates
[
SettingKeyCustomEndpoints
]
=
settings
.
CustomEndpoints
...
...
@@ -824,6 +847,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeySiteLogo
:
""
,
SettingKeyPurchaseSubscriptionEnabled
:
"false"
,
SettingKeyPurchaseSubscriptionURL
:
""
,
SettingKeyTableDefaultPageSize
:
"20"
,
SettingKeyTablePageSizeOptions
:
"[10,20,50,100]"
,
SettingKeyCustomMenuItems
:
"[]"
,
SettingKeyCustomEndpoints
:
"[]"
,
SettingKeyDefaultConcurrency
:
strconv
.
Itoa
(
s
.
cfg
.
Default
.
UserConcurrency
),
...
...
@@ -893,6 +918,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
CustomEndpoints
:
settings
[
SettingKeyCustomEndpoints
],
BackendModeEnabled
:
settings
[
SettingKeyBackendModeEnabled
]
==
"true"
,
}
result
.
TableDefaultPageSize
,
result
.
TablePageSizeOptions
=
parseTablePreferences
(
settings
[
SettingKeyTableDefaultPageSize
],
settings
[
SettingKeyTablePageSizeOptions
],
)
// 解析整数类型
if
port
,
err
:=
strconv
.
Atoi
(
settings
[
SettingKeySMTPPort
]);
err
==
nil
{
...
...
@@ -1036,6 +1065,59 @@ func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting {
return
normalized
}
func
parseTablePreferences
(
defaultPageSizeRaw
,
optionsRaw
string
)
(
int
,
[]
int
)
{
defaultPageSize
:=
20
if
v
,
err
:=
strconv
.
Atoi
(
strings
.
TrimSpace
(
defaultPageSizeRaw
));
err
==
nil
{
defaultPageSize
=
v
}
var
options
[]
int
if
strings
.
TrimSpace
(
optionsRaw
)
!=
""
{
_
=
json
.
Unmarshal
([]
byte
(
optionsRaw
),
&
options
)
}
return
normalizeTablePreferences
(
defaultPageSize
,
options
)
}
func
normalizeTablePreferences
(
defaultPageSize
int
,
options
[]
int
)
(
int
,
[]
int
)
{
const
minPageSize
=
5
const
maxPageSize
=
1000
const
fallbackPageSize
=
20
seen
:=
make
(
map
[
int
]
struct
{},
len
(
options
))
normalizedOptions
:=
make
([]
int
,
0
,
len
(
options
))
for
_
,
option
:=
range
options
{
if
option
<
minPageSize
||
option
>
maxPageSize
{
continue
}
if
_
,
ok
:=
seen
[
option
];
ok
{
continue
}
seen
[
option
]
=
struct
{}{}
normalizedOptions
=
append
(
normalizedOptions
,
option
)
}
sort
.
Ints
(
normalizedOptions
)
if
defaultPageSize
<
minPageSize
||
defaultPageSize
>
maxPageSize
{
defaultPageSize
=
fallbackPageSize
}
if
len
(
normalizedOptions
)
==
0
{
normalizedOptions
=
[]
int
{
10
,
20
,
50
}
}
return
defaultPageSize
,
normalizedOptions
}
func
containsInt
(
values
[]
int
,
target
int
)
bool
{
for
_
,
value
:=
range
values
{
if
value
==
target
{
return
true
}
}
return
false
}
// getStringOrDefault 获取字符串值或默认值
func
(
s
*
SettingService
)
getStringOrDefault
(
settings
map
[
string
]
string
,
key
,
defaultValue
string
)
string
{
if
value
,
ok
:=
settings
[
key
];
ok
&&
value
!=
""
{
...
...
backend/internal/service/setting_service_public_test.go
View file @
ad80606a
...
...
@@ -62,3 +62,18 @@ func TestSettingService_GetPublicSettings_ExposesRegistrationEmailSuffixWhitelis
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
[]
string
{
"@example.com"
,
"@foo.bar"
},
settings
.
RegistrationEmailSuffixWhitelist
)
}
func
TestSettingService_GetPublicSettings_ExposesTablePreferences
(
t
*
testing
.
T
)
{
repo
:=
&
settingPublicRepoStub
{
values
:
map
[
string
]
string
{
SettingKeyTableDefaultPageSize
:
"50"
,
SettingKeyTablePageSizeOptions
:
"[20,50,100]"
,
},
}
svc
:=
NewSettingService
(
repo
,
&
config
.
Config
{})
settings
,
err
:=
svc
.
GetPublicSettings
(
context
.
Background
())
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
50
,
settings
.
TableDefaultPageSize
)
require
.
Equal
(
t
,
[]
int
{
20
,
50
,
100
},
settings
.
TablePageSizeOptions
)
}
backend/internal/service/setting_service_update_test.go
View file @
ad80606a
...
...
@@ -202,3 +202,24 @@ func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) {
{
GroupID
:
12
,
ValidityDays
:
MaxValidityDays
},
},
got
)
}
func
TestSettingService_UpdateSettings_TablePreferences
(
t
*
testing
.
T
)
{
repo
:=
&
settingUpdateRepoStub
{}
svc
:=
NewSettingService
(
repo
,
&
config
.
Config
{})
err
:=
svc
.
UpdateSettings
(
context
.
Background
(),
&
SystemSettings
{
TableDefaultPageSize
:
50
,
TablePageSizeOptions
:
[]
int
{
20
,
50
,
100
},
})
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"50"
,
repo
.
updates
[
SettingKeyTableDefaultPageSize
])
require
.
Equal
(
t
,
"[20,50,100]"
,
repo
.
updates
[
SettingKeyTablePageSizeOptions
])
err
=
svc
.
UpdateSettings
(
context
.
Background
(),
&
SystemSettings
{
TableDefaultPageSize
:
1000
,
TablePageSizeOptions
:
[]
int
{
20
,
100
},
})
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"1000"
,
repo
.
updates
[
SettingKeyTableDefaultPageSize
])
require
.
Equal
(
t
,
"[20,100]"
,
repo
.
updates
[
SettingKeyTablePageSizeOptions
])
}
backend/internal/service/settings_view.go
View file @
ad80606a
...
...
@@ -41,6 +41,8 @@ type SystemSettings struct {
HideCcsImportButton
bool
PurchaseSubscriptionEnabled
bool
PurchaseSubscriptionURL
string
TableDefaultPageSize
int
TablePageSizeOptions
[]
int
CustomMenuItems
string
// JSON array of custom menu items
CustomEndpoints
string
// JSON array of custom endpoints
...
...
@@ -107,6 +109,8 @@ type PublicSettings struct {
PurchaseSubscriptionEnabled
bool
PurchaseSubscriptionURL
string
TableDefaultPageSize
int
TablePageSizeOptions
[]
int
CustomMenuItems
string
// JSON array of custom menu items
CustomEndpoints
string
// JSON array of custom endpoints
...
...
frontend/src/api/admin/settings.ts
View file @
ad80606a
...
...
@@ -40,6 +40,8 @@ export interface SystemSettings {
hide_ccs_import_button
:
boolean
purchase_subscription_enabled
:
boolean
purchase_subscription_url
:
string
table_default_page_size
:
number
table_page_size_options
:
number
[]
backend_mode_enabled
:
boolean
custom_menu_items
:
CustomMenuItem
[]
custom_endpoints
:
CustomEndpoint
[]
...
...
@@ -114,6 +116,8 @@ export interface UpdateSettingsRequest {
hide_ccs_import_button
?:
boolean
purchase_subscription_enabled
?:
boolean
purchase_subscription_url
?:
string
table_default_page_size
?:
number
table_page_size_options
?:
number
[]
backend_mode_enabled
?:
boolean
custom_menu_items
?:
CustomMenuItem
[]
custom_endpoints
?:
CustomEndpoint
[]
...
...
frontend/src/components/common/Pagination.vue
View file @
ad80606a
...
...
@@ -123,6 +123,7 @@ import { useI18n } from 'vue-i18n'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Select
from
'
./Select.vue
'
import
{
setPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
import
{
getConfiguredTablePageSizeOptions
,
normalizeTablePageSize
}
from
'
@/utils/tablePreferences
'
const
{
t
}
=
useI18n
()
...
...
@@ -141,7 +142,7 @@ interface Emits {
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
pageSizeOptions
:
()
=>
[
10
,
20
,
50
,
100
]
,
pageSizeOptions
:
()
=>
getConfiguredTablePageSizeOptions
()
,
showPageSizeSelector
:
true
,
showJump
:
false
}
)
...
...
@@ -161,7 +162,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,7 +224,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
const
newPageSize
=
normalizeTablePageSize
(
typeof
value
===
'
string
'
?
parseInt
(
value
,
10
)
:
value
)
setPersistedPageSize
(
newPageSize
)
emit
(
'
update:pageSize
'
,
newPageSize
)
}
...
...
frontend/src/composables/usePersistedPageSize.ts
View file @
ad80606a
import
{
getConfiguredTableDefaultPageSize
,
normalizeTablePageSize
}
from
'
@/utils/tablePreferences
'
const
STORAGE_KEY
=
'
table-page-size
'
const
DEFAULT_PAGE_SIZE
=
20
const
SOURCE_KEY
=
'
table-page-size-source
'
/**
* 从 localStorage 读取/写入 pageSize
* 全局共享一个 key,所有表格统一偏好
*/
export
function
getPersistedPageSize
(
fallback
=
DEFAULT_PAGE_SIZE
):
number
{
export
function
getPersistedPageSize
(
fallback
=
getConfiguredTableDefaultPageSize
()
):
number
{
try
{
const
stored
=
localStorage
.
getItem
(
STORAGE_KEY
)
if
(
stored
)
{
const
parsed
=
Number
(
stored
)
if
(
Number
.
isFinite
(
parsed
)
&&
parsed
>
0
)
return
parsed
return
normalizeTablePageSize
(
stored
)
}
}
catch
{
// localStorage 不可用(隐私模式等)
}
return
fallback
return
normalizeTablePageSize
(
fallback
)
}
export
function
setPersistedPageSize
(
size
:
number
):
void
{
try
{
localStorage
.
setItem
(
STORAGE_KEY
,
String
(
size
))
localStorage
.
setItem
(
STORAGE_KEY
,
String
(
normalizeTablePageSize
(
size
)))
localStorage
.
setItem
(
SOURCE_KEY
,
'
user
'
)
}
catch
{
// 静默失败
}
}
export
function
syncPersistedPageSizeWithSystemDefault
(
defaultSize
=
getConfiguredTableDefaultPageSize
()):
void
{
try
{
const
normalizedDefault
=
normalizeTablePageSize
(
defaultSize
)
const
stored
=
localStorage
.
getItem
(
STORAGE_KEY
)
const
source
=
localStorage
.
getItem
(
SOURCE_KEY
)
const
normalizedStored
=
stored
?
normalizeTablePageSize
(
stored
)
:
null
if
((
source
===
'
user
'
||
(
source
===
null
&&
stored
!==
null
))
&&
stored
)
{
localStorage
.
setItem
(
STORAGE_KEY
,
String
(
normalizedStored
??
normalizedDefault
))
localStorage
.
setItem
(
SOURCE_KEY
,
'
user
'
)
return
}
localStorage
.
setItem
(
STORAGE_KEY
,
String
(
normalizedDefault
))
localStorage
.
setItem
(
SOURCE_KEY
,
'
system
'
)
}
catch
{
// 静默失败
}
...
...
frontend/src/stores/__tests__/app.spec.ts
View file @
ad80606a
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,120 @@ 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
'
)).
toBe
(
'
1000
'
)
expect
(
localStorage
.
getItem
(
'
table-page-size-source
'
)).
toBe
(
'
system
'
)
})
it
(
'
fetchPublicSettings(force) 保留用户显式选择的分页大小
'
,
async
()
=>
{
localStorage
.
setItem
(
'
table-page-size
'
,
'
100
'
)
localStorage
.
setItem
(
'
table-page-size-source
'
,
'
user
'
)
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
,
50
,
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
(
localStorage
.
getItem
(
'
table-page-size
'
)).
toBe
(
'
1000
'
)
expect
(
localStorage
.
getItem
(
'
table-page-size-source
'
)).
toBe
(
'
user
'
)
})
it
(
'
fetchPublicSettings(force) 保留旧版本未标记来源的分页偏好
'
,
async
()
=>
{
localStorage
.
setItem
(
'
table-page-size
'
,
'
50
'
)
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
,
50
,
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
(
localStorage
.
getItem
(
'
table-page-size
'
)).
toBe
(
'
50
'
)
expect
(
localStorage
.
getItem
(
'
table-page-size-source
'
)).
toBe
(
'
user
'
)
})
})
})
frontend/src/stores/app.ts
View file @
ad80606a
...
...
@@ -12,6 +12,7 @@ import {
type
ReleaseInfo
}
from
'
@/api/admin/system
'
import
{
getPublicSettings
as
fetchPublicSettingsAPI
}
from
'
@/api/auth
'
import
{
syncPersistedPageSizeWithSystemDefault
}
from
'
@/composables/usePersistedPageSize
'
export
const
useAppStore
=
defineStore
(
'
app
'
,
()
=>
{
// ==================== State ====================
...
...
@@ -284,6 +285,10 @@ 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
}
}
syncPersistedPageSizeWithSystemDefault
(
config
.
table_default_page_size
)
cachedPublicSettings
.
value
=
config
siteName
.
value
=
config
.
site_name
||
'
Sub2API
'
siteLogo
.
value
=
config
.
site_logo
||
''
...
...
@@ -329,6 +334,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
],
custom_menu_items
:
[],
custom_endpoints
:
[],
linuxdo_oauth_enabled
:
false
,
...
...
frontend/src/utils/__tests__/tablePreferences.spec.ts
0 → 100644
View file @
ad80606a
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
'
,
()
=>
{
window
.
__APP_CONFIG__
=
{
table_default_page_size
:
1000
}
as
any
expect
(
getConfiguredTablePageSizeOptions
()).
toEqual
([
10
,
20
,
50
])
})
})
frontend/src/utils/tablePreferences.ts
0 → 100644
View file @
ad80606a
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
]
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/SettingsView.vue
View file @
ad80606a
...
...
@@ -1468,6 +1468,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
"
>
...
...
@@ -2125,6 +2167,7 @@ const smtpPasswordManuallyEdited = ref(false)
const
testEmailAddress
=
ref
(
''
)
const
registrationEmailSuffixWhitelistTags
=
ref
<
string
[]
>
([])
const
registrationEmailSuffixWhitelistDraft
=
ref
(
''
)
const
tablePageSizeOptionsInput
=
ref
(
'
10, 20, 50
'
)
// Admin API Key 状态
const
adminApiKeyLoading
=
ref
(
true
)
...
...
@@ -2179,6 +2222,10 @@ const betaPolicyForm = reactive({
}
>
}
)
const
tablePageSizeMin
=
5
const
tablePageSizeMax
=
1000
const
tablePageSizeDefault
=
20
interface
DefaultSubscriptionGroupOption
{
value
:
number
label
:
string
...
...
@@ -2218,6 +2265,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
],
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
:
''
,
...
...
@@ -2402,6 +2451,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
...
...
@@ -2420,6 +2498,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
]
)
registrationEmailSuffixWhitelistDraft
.
value
=
''
form
.
smtp_password
=
''
smtpPasswordManuallyEdited
.
value
=
false
...
...
@@ -2465,6 +2546,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
)
=>
({
...
...
@@ -2542,6 +2654,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
,
...
...
@@ -2578,6 +2692,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
]
)
registrationEmailSuffixWhitelistDraft
.
value
=
''
form
.
smtp_password
=
''
smtpPasswordManuallyEdited
.
value
=
false
...
...
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