diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 4cbe5188..356ee36a 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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"` diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 73707f79..1b9372e6 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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"` diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 7d0ef5bd..573dc41d 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -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 != "" { diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index b511cd29..6dfa627c 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -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) +} diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 1de08611..28c7ad02 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -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]) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index fedb3f2f..ac065d9a 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -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 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b7ee6be5..e89b4a86 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -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[] diff --git a/frontend/src/components/common/Pagination.vue b/frontend/src/components/common/Pagination.vue index abb0e566..f7f69aaa 100644 --- a/frontend/src/components/common/Pagination.vue +++ b/frontend/src/components/common/Pagination.vue @@ -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(), { - 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) } diff --git a/frontend/src/composables/usePersistedPageSize.ts b/frontend/src/composables/usePersistedPageSize.ts index 9e173a1d..c2bfae42 100644 --- a/frontend/src/composables/usePersistedPageSize.ts +++ b/frontend/src/composables/usePersistedPageSize.ts @@ -1,26 +1,48 @@ +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 { // 静默失败 } diff --git a/frontend/src/stores/__tests__/app.spec.ts b/frontend/src/stores/__tests__/app.spec.ts index 30ba5c8f..efc7f1bf 100644 --- a/frontend/src/stores/__tests__/app.spec.ts +++ b/frontend/src/stores/__tests__/app.spec.ts @@ -1,6 +1,7 @@ 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') + }) }) }) diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 24057136..efa4c4da 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -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, diff --git a/frontend/src/utils/__tests__/tablePreferences.spec.ts b/frontend/src/utils/__tests__/tablePreferences.spec.ts new file mode 100644 index 00000000..f18ec215 --- /dev/null +++ b/frontend/src/utils/__tests__/tablePreferences.spec.ts @@ -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]) + }) +}) diff --git a/frontend/src/utils/tablePreferences.ts b/frontend/src/utils/tablePreferences.ts new file mode 100644 index 00000000..1b123851 --- /dev/null +++ b/frontend/src/utils/tablePreferences.ts @@ -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) +} diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index f43140ab..8849bb69 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1468,6 +1468,48 @@

+ +
+

+ {{ t('admin.settings.site.tablePreferencesTitle') }} +

+

+ {{ t('admin.settings.site.tablePreferencesDescription') }} +

+
+
+ + +

+ {{ t('admin.settings.site.tableDefaultPageSizeHint') }} +

+
+
+ + +

+ {{ t('admin.settings.site.tablePageSizeOptionsHint') }} +

+
+
+
+