feat(settings): 增加全局表格分页配置,支持自定义
This commit is contained in:
@@ -106,6 +106,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
HideCcsImportButton: settings.HideCcsImportButton,
|
HideCcsImportButton: settings.HideCcsImportButton,
|
||||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||||
|
TableDefaultPageSize: settings.TableDefaultPageSize,
|
||||||
|
TablePageSizeOptions: settings.TablePageSizeOptions,
|
||||||
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
|
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
|
||||||
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
||||||
DefaultConcurrency: settings.DefaultConcurrency,
|
DefaultConcurrency: settings.DefaultConcurrency,
|
||||||
@@ -175,6 +177,8 @@ type UpdateSettingsRequest struct {
|
|||||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||||
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
|
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
|
||||||
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
|
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"`
|
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
|
||||||
CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"`
|
CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"`
|
||||||
|
|
||||||
@@ -237,6 +241,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
if req.DefaultBalance < 0 {
|
if req.DefaultBalance < 0 {
|
||||||
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.SMTPHost = strings.TrimSpace(req.SMTPHost)
|
||||||
req.SMTPUsername = strings.TrimSpace(req.SMTPUsername)
|
req.SMTPUsername = strings.TrimSpace(req.SMTPUsername)
|
||||||
req.SMTPPassword = strings.TrimSpace(req.SMTPPassword)
|
req.SMTPPassword = strings.TrimSpace(req.SMTPPassword)
|
||||||
@@ -564,6 +575,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
HideCcsImportButton: req.HideCcsImportButton,
|
HideCcsImportButton: req.HideCcsImportButton,
|
||||||
PurchaseSubscriptionEnabled: purchaseEnabled,
|
PurchaseSubscriptionEnabled: purchaseEnabled,
|
||||||
PurchaseSubscriptionURL: purchaseURL,
|
PurchaseSubscriptionURL: purchaseURL,
|
||||||
|
TableDefaultPageSize: req.TableDefaultPageSize,
|
||||||
|
TablePageSizeOptions: req.TablePageSizeOptions,
|
||||||
CustomMenuItems: customMenuJSON,
|
CustomMenuItems: customMenuJSON,
|
||||||
CustomEndpoints: customEndpointsJSON,
|
CustomEndpoints: customEndpointsJSON,
|
||||||
DefaultConcurrency: req.DefaultConcurrency,
|
DefaultConcurrency: req.DefaultConcurrency,
|
||||||
@@ -679,6 +692,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
HideCcsImportButton: updatedSettings.HideCcsImportButton,
|
HideCcsImportButton: updatedSettings.HideCcsImportButton,
|
||||||
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
|
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
|
||||||
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
|
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
|
||||||
|
TableDefaultPageSize: updatedSettings.TableDefaultPageSize,
|
||||||
|
TablePageSizeOptions: updatedSettings.TablePageSizeOptions,
|
||||||
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
|
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
|
||||||
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
|
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
|
||||||
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
||||||
@@ -871,6 +886,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.PurchaseSubscriptionURL != after.PurchaseSubscriptionURL {
|
if before.PurchaseSubscriptionURL != after.PurchaseSubscriptionURL {
|
||||||
changed = append(changed, "purchase_subscription_url")
|
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 {
|
if before.CustomMenuItems != after.CustomMenuItems {
|
||||||
changed = append(changed, "custom_menu_items")
|
changed = append(changed, "custom_menu_items")
|
||||||
}
|
}
|
||||||
@@ -927,6 +948,18 @@ func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
|
|||||||
return true
|
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连接请求
|
// TestSMTPRequest 测试SMTP连接请求
|
||||||
type TestSMTPRequest struct {
|
type TestSMTPRequest struct {
|
||||||
SMTPHost string `json:"smtp_host"`
|
SMTPHost string `json:"smtp_host"`
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ type SystemSettings struct {
|
|||||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
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"`
|
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||||
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
||||||
|
|
||||||
@@ -125,6 +127,8 @@ type PublicSettings struct {
|
|||||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
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"`
|
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||||
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -160,6 +161,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyHideCcsImportButton,
|
SettingKeyHideCcsImportButton,
|
||||||
SettingKeyPurchaseSubscriptionEnabled,
|
SettingKeyPurchaseSubscriptionEnabled,
|
||||||
SettingKeyPurchaseSubscriptionURL,
|
SettingKeyPurchaseSubscriptionURL,
|
||||||
|
SettingKeyTableDefaultPageSize,
|
||||||
|
SettingKeyTablePageSizeOptions,
|
||||||
SettingKeyCustomMenuItems,
|
SettingKeyCustomMenuItems,
|
||||||
SettingKeyCustomEndpoints,
|
SettingKeyCustomEndpoints,
|
||||||
SettingKeyLinuxDoConnectEnabled,
|
SettingKeyLinuxDoConnectEnabled,
|
||||||
@@ -184,6 +187,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
|
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
|
||||||
settings[SettingKeyRegistrationEmailSuffixWhitelist],
|
settings[SettingKeyRegistrationEmailSuffixWhitelist],
|
||||||
)
|
)
|
||||||
|
tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences(
|
||||||
|
settings[SettingKeyTableDefaultPageSize],
|
||||||
|
settings[SettingKeyTablePageSizeOptions],
|
||||||
|
)
|
||||||
|
|
||||||
return &PublicSettings{
|
return &PublicSettings{
|
||||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||||
@@ -205,6 +212,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||||
|
TableDefaultPageSize: tableDefaultPageSize,
|
||||||
|
TablePageSizeOptions: tablePageSizeOptions,
|
||||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||||
@@ -252,6 +261,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
|
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"`
|
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||||
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
@@ -277,6 +288,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
HideCcsImportButton: settings.HideCcsImportButton,
|
HideCcsImportButton: settings.HideCcsImportButton,
|
||||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||||
|
TableDefaultPageSize: settings.TableDefaultPageSize,
|
||||||
|
TablePageSizeOptions: settings.TablePageSizeOptions,
|
||||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
@@ -471,6 +484,16 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|||||||
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
|
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
|
||||||
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
||||||
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
|
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[SettingKeyCustomMenuItems] = settings.CustomMenuItems
|
||||||
updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints
|
updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints
|
||||||
|
|
||||||
@@ -824,6 +847,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
SettingKeySiteLogo: "",
|
SettingKeySiteLogo: "",
|
||||||
SettingKeyPurchaseSubscriptionEnabled: "false",
|
SettingKeyPurchaseSubscriptionEnabled: "false",
|
||||||
SettingKeyPurchaseSubscriptionURL: "",
|
SettingKeyPurchaseSubscriptionURL: "",
|
||||||
|
SettingKeyTableDefaultPageSize: "20",
|
||||||
|
SettingKeyTablePageSizeOptions: "[10,20,50,100]",
|
||||||
SettingKeyCustomMenuItems: "[]",
|
SettingKeyCustomMenuItems: "[]",
|
||||||
SettingKeyCustomEndpoints: "[]",
|
SettingKeyCustomEndpoints: "[]",
|
||||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||||
@@ -893,6 +918,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||||
}
|
}
|
||||||
|
result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences(
|
||||||
|
settings[SettingKeyTableDefaultPageSize],
|
||||||
|
settings[SettingKeyTablePageSizeOptions],
|
||||||
|
)
|
||||||
|
|
||||||
// 解析整数类型
|
// 解析整数类型
|
||||||
if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil {
|
if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil {
|
||||||
@@ -1036,6 +1065,59 @@ func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting {
|
|||||||
return normalized
|
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 获取字符串值或默认值
|
// getStringOrDefault 获取字符串值或默认值
|
||||||
func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string {
|
func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string {
|
||||||
if value, ok := settings[key]; ok && value != "" {
|
if value, ok := settings[key]; ok && value != "" {
|
||||||
|
|||||||
@@ -62,3 +62,18 @@ func TestSettingService_GetPublicSettings_ExposesRegistrationEmailSuffixWhitelis
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []string{"@example.com", "@foo.bar"}, settings.RegistrationEmailSuffixWhitelist)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,3 +202,24 @@ func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) {
|
|||||||
{GroupID: 12, ValidityDays: MaxValidityDays},
|
{GroupID: 12, ValidityDays: MaxValidityDays},
|
||||||
}, got)
|
}, 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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ type SystemSettings struct {
|
|||||||
HideCcsImportButton bool
|
HideCcsImportButton bool
|
||||||
PurchaseSubscriptionEnabled bool
|
PurchaseSubscriptionEnabled bool
|
||||||
PurchaseSubscriptionURL string
|
PurchaseSubscriptionURL string
|
||||||
|
TableDefaultPageSize int
|
||||||
|
TablePageSizeOptions []int
|
||||||
CustomMenuItems string // JSON array of custom menu items
|
CustomMenuItems string // JSON array of custom menu items
|
||||||
CustomEndpoints string // JSON array of custom endpoints
|
CustomEndpoints string // JSON array of custom endpoints
|
||||||
|
|
||||||
@@ -107,6 +109,8 @@ type PublicSettings struct {
|
|||||||
|
|
||||||
PurchaseSubscriptionEnabled bool
|
PurchaseSubscriptionEnabled bool
|
||||||
PurchaseSubscriptionURL string
|
PurchaseSubscriptionURL string
|
||||||
|
TableDefaultPageSize int
|
||||||
|
TablePageSizeOptions []int
|
||||||
CustomMenuItems string // JSON array of custom menu items
|
CustomMenuItems string // JSON array of custom menu items
|
||||||
CustomEndpoints string // JSON array of custom endpoints
|
CustomEndpoints string // JSON array of custom endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface SystemSettings {
|
|||||||
hide_ccs_import_button: boolean
|
hide_ccs_import_button: boolean
|
||||||
purchase_subscription_enabled: boolean
|
purchase_subscription_enabled: boolean
|
||||||
purchase_subscription_url: string
|
purchase_subscription_url: string
|
||||||
|
table_default_page_size: number
|
||||||
|
table_page_size_options: number[]
|
||||||
backend_mode_enabled: boolean
|
backend_mode_enabled: boolean
|
||||||
custom_menu_items: CustomMenuItem[]
|
custom_menu_items: CustomMenuItem[]
|
||||||
custom_endpoints: CustomEndpoint[]
|
custom_endpoints: CustomEndpoint[]
|
||||||
@@ -114,6 +116,8 @@ export interface UpdateSettingsRequest {
|
|||||||
hide_ccs_import_button?: boolean
|
hide_ccs_import_button?: boolean
|
||||||
purchase_subscription_enabled?: boolean
|
purchase_subscription_enabled?: boolean
|
||||||
purchase_subscription_url?: string
|
purchase_subscription_url?: string
|
||||||
|
table_default_page_size?: number
|
||||||
|
table_page_size_options?: number[]
|
||||||
backend_mode_enabled?: boolean
|
backend_mode_enabled?: boolean
|
||||||
custom_menu_items?: CustomMenuItem[]
|
custom_menu_items?: CustomMenuItem[]
|
||||||
custom_endpoints?: CustomEndpoint[]
|
custom_endpoints?: CustomEndpoint[]
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import Select from './Select.vue'
|
import Select from './Select.vue'
|
||||||
import { setPersistedPageSize } from '@/composables/usePersistedPageSize'
|
import { setPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||||
|
import { getConfiguredTablePageSizeOptions, normalizeTablePageSize } from '@/utils/tablePreferences'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ interface Emits {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
pageSizeOptions: () => [10, 20, 50, 100],
|
pageSizeOptions: () => getConfiguredTablePageSizeOptions(),
|
||||||
showPageSizeSelector: true,
|
showPageSizeSelector: true,
|
||||||
showJump: false
|
showJump: false
|
||||||
})
|
})
|
||||||
@@ -161,7 +162,14 @@ const toItem = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const pageSizeSelectOptions = 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,
|
value: size,
|
||||||
label: String(size)
|
label: String(size)
|
||||||
}))
|
}))
|
||||||
@@ -216,7 +224,7 @@ const goToPage = (newPage: number) => {
|
|||||||
|
|
||||||
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||||
if (value === null || typeof value === 'boolean') return
|
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)
|
setPersistedPageSize(newPageSize)
|
||||||
emit('update:pageSize', newPageSize)
|
emit('update:pageSize', newPageSize)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,48 @@
|
|||||||
|
import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences'
|
||||||
|
|
||||||
const STORAGE_KEY = 'table-page-size'
|
const STORAGE_KEY = 'table-page-size'
|
||||||
const DEFAULT_PAGE_SIZE = 20
|
const SOURCE_KEY = 'table-page-size-source'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 localStorage 读取/写入 pageSize
|
* 从 localStorage 读取/写入 pageSize
|
||||||
* 全局共享一个 key,所有表格统一偏好
|
* 全局共享一个 key,所有表格统一偏好
|
||||||
*/
|
*/
|
||||||
export function getPersistedPageSize(fallback = DEFAULT_PAGE_SIZE): number {
|
export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = Number(stored)
|
return normalizeTablePageSize(stored)
|
||||||
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage 不可用(隐私模式等)
|
// localStorage 不可用(隐私模式等)
|
||||||
}
|
}
|
||||||
return fallback
|
return normalizeTablePageSize(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setPersistedPageSize(size: number): void {
|
export function setPersistedPageSize(size: number): void {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// 静默失败
|
// 静默失败
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { getPublicSettings } from '@/api/auth'
|
||||||
|
|
||||||
// Mock API 模块
|
// Mock API 模块
|
||||||
vi.mock('@/api/admin/system', () => ({
|
vi.mock('@/api/admin/system', () => ({
|
||||||
@@ -15,12 +16,14 @@ describe('useAppStore', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
localStorage.clear()
|
||||||
// 清除 window.__APP_CONFIG__
|
// 清除 window.__APP_CONFIG__
|
||||||
delete (window as any).__APP_CONFIG__
|
delete (window as any).__APP_CONFIG__
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Toast 消息管理 ---
|
// --- Toast 消息管理 ---
|
||||||
@@ -291,5 +294,120 @@ describe('useAppStore', () => {
|
|||||||
expect(store.publicSettingsLoaded).toBe(false)
|
expect(store.publicSettingsLoaded).toBe(false)
|
||||||
expect(store.cachedPublicSettings).toBeNull()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ReleaseInfo
|
type ReleaseInfo
|
||||||
} from '@/api/admin/system'
|
} from '@/api/admin/system'
|
||||||
import { getPublicSettings as fetchPublicSettingsAPI } from '@/api/auth'
|
import { getPublicSettings as fetchPublicSettingsAPI } from '@/api/auth'
|
||||||
|
import { syncPersistedPageSizeWithSystemDefault } from '@/composables/usePersistedPageSize'
|
||||||
|
|
||||||
export const useAppStore = defineStore('app', () => {
|
export const useAppStore = defineStore('app', () => {
|
||||||
// ==================== State ====================
|
// ==================== State ====================
|
||||||
@@ -284,6 +285,10 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
* Apply settings to store state (internal helper to avoid code duplication)
|
* Apply settings to store state (internal helper to avoid code duplication)
|
||||||
*/
|
*/
|
||||||
function applySettings(config: PublicSettings): void {
|
function applySettings(config: PublicSettings): void {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__APP_CONFIG__ = { ...config }
|
||||||
|
}
|
||||||
|
syncPersistedPageSizeWithSystemDefault(config.table_default_page_size)
|
||||||
cachedPublicSettings.value = config
|
cachedPublicSettings.value = config
|
||||||
siteName.value = config.site_name || 'Sub2API'
|
siteName.value = config.site_name || 'Sub2API'
|
||||||
siteLogo.value = config.site_logo || ''
|
siteLogo.value = config.site_logo || ''
|
||||||
@@ -329,6 +334,8 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
hide_ccs_import_button: false,
|
hide_ccs_import_button: false,
|
||||||
purchase_subscription_enabled: false,
|
purchase_subscription_enabled: false,
|
||||||
purchase_subscription_url: '',
|
purchase_subscription_url: '',
|
||||||
|
table_default_page_size: 20,
|
||||||
|
table_page_size_options: [10, 20, 50],
|
||||||
custom_menu_items: [],
|
custom_menu_items: [],
|
||||||
custom_endpoints: [],
|
custom_endpoints: [],
|
||||||
linuxdo_oauth_enabled: false,
|
linuxdo_oauth_enabled: false,
|
||||||
|
|||||||
74
frontend/src/utils/__tests__/tablePreferences.spec.ts
Normal file
74
frontend/src/utils/__tests__/tablePreferences.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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])
|
||||||
|
})
|
||||||
|
})
|
||||||
73
frontend/src/utils/tablePreferences.ts
Normal file
73
frontend/src/utils/tablePreferences.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1468,6 +1468,48 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Custom Endpoints -->
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<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 testEmailAddress = ref('')
|
||||||
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
|
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
|
||||||
const registrationEmailSuffixWhitelistDraft = ref('')
|
const registrationEmailSuffixWhitelistDraft = ref('')
|
||||||
|
const tablePageSizeOptionsInput = ref('10, 20, 50')
|
||||||
|
|
||||||
// Admin API Key 状态
|
// Admin API Key 状态
|
||||||
const adminApiKeyLoading = ref(true)
|
const adminApiKeyLoading = ref(true)
|
||||||
@@ -2179,6 +2222,10 @@ const betaPolicyForm = reactive({
|
|||||||
}>
|
}>
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tablePageSizeMin = 5
|
||||||
|
const tablePageSizeMax = 1000
|
||||||
|
const tablePageSizeDefault = 20
|
||||||
|
|
||||||
interface DefaultSubscriptionGroupOption {
|
interface DefaultSubscriptionGroupOption {
|
||||||
value: number
|
value: number
|
||||||
label: string
|
label: string
|
||||||
@@ -2218,6 +2265,8 @@ const form = reactive<SettingsForm>({
|
|||||||
hide_ccs_import_button: false,
|
hide_ccs_import_button: false,
|
||||||
purchase_subscription_enabled: false,
|
purchase_subscription_enabled: false,
|
||||||
purchase_subscription_url: '',
|
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_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}>,
|
custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
|
||||||
frontend_url: '',
|
frontend_url: '',
|
||||||
@@ -2402,6 +2451,35 @@ function removeEndpoint(index: number) {
|
|||||||
form.custom_endpoints.splice(index, 1)
|
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() {
|
async function loadSettings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadFailed.value = false
|
loadFailed.value = false
|
||||||
@@ -2420,6 +2498,9 @@ async function loadSettings() {
|
|||||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||||
settings.registration_email_suffix_whitelist
|
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 = ''
|
registrationEmailSuffixWhitelistDraft.value = ''
|
||||||
form.smtp_password = ''
|
form.smtp_password = ''
|
||||||
smtpPasswordManuallyEdited.value = false
|
smtpPasswordManuallyEdited.value = false
|
||||||
@@ -2465,6 +2546,37 @@ function removeDefaultSubscription(index: number) {
|
|||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
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
|
const normalizedDefaultSubscriptions = form.default_subscriptions
|
||||||
.filter((item) => item.group_id > 0 && item.validity_days > 0)
|
.filter((item) => item.group_id > 0 && item.validity_days > 0)
|
||||||
.map((item: DefaultSubscriptionSetting) => ({
|
.map((item: DefaultSubscriptionSetting) => ({
|
||||||
@@ -2542,6 +2654,8 @@ async function saveSettings() {
|
|||||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||||
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
||||||
purchase_subscription_url: form.purchase_subscription_url,
|
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_menu_items: form.custom_menu_items,
|
||||||
custom_endpoints: form.custom_endpoints,
|
custom_endpoints: form.custom_endpoints,
|
||||||
frontend_url: form.frontend_url,
|
frontend_url: form.frontend_url,
|
||||||
@@ -2578,6 +2692,9 @@ async function saveSettings() {
|
|||||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||||
updated.registration_email_suffix_whitelist
|
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 = ''
|
registrationEmailSuffixWhitelistDraft.value = ''
|
||||||
form.smtp_password = ''
|
form.smtp_password = ''
|
||||||
smtpPasswordManuallyEdited.value = false
|
smtpPasswordManuallyEdited.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user