From ad80606a4483ac9fb11f9ccc76ff09b3590fc627 Mon Sep 17 00:00:00 2001
From: IanShaw027
Date: Thu, 9 Apr 2026 18:14:28 +0800
Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E5=A2=9E=E5=8A=A0=E5=85=A8?=
=?UTF-8?q?=E5=B1=80=E8=A1=A8=E6=A0=BC=E5=88=86=E9=A1=B5=E9=85=8D=E7=BD=AE?=
=?UTF-8?q?,=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../internal/handler/admin/setting_handler.go | 33 +++++
backend/internal/handler/dto/settings.go | 4 +
backend/internal/service/setting_service.go | 82 ++++++++++++
.../service/setting_service_public_test.go | 15 +++
.../service/setting_service_update_test.go | 21 ++++
backend/internal/service/settings_view.go | 4 +
frontend/src/api/admin/settings.ts | 4 +
frontend/src/components/common/Pagination.vue | 14 ++-
.../src/composables/usePersistedPageSize.ts | 34 ++++-
frontend/src/stores/__tests__/app.spec.ts | 118 ++++++++++++++++++
frontend/src/stores/app.ts | 7 ++
.../utils/__tests__/tablePreferences.spec.ts | 74 +++++++++++
frontend/src/utils/tablePreferences.ts | 73 +++++++++++
frontend/src/views/admin/SettingsView.vue | 117 +++++++++++++++++
14 files changed, 591 insertions(+), 9 deletions(-)
create mode 100644 frontend/src/utils/__tests__/tablePreferences.spec.ts
create mode 100644 frontend/src/utils/tablePreferences.ts
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') }}
+
+
+
+
+