feat(settings): 增加全局表格分页配置,支持自定义
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -123,6 +123,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from './Select.vue'
|
||||
import { setPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
import { getConfiguredTablePageSizeOptions, normalizeTablePageSize } from '@/utils/tablePreferences'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -141,7 +142,7 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSizeOptions: () => [10, 20, 50, 100],
|
||||
pageSizeOptions: () => getConfiguredTablePageSizeOptions(),
|
||||
showPageSizeSelector: true,
|
||||
showJump: false
|
||||
})
|
||||
@@ -161,7 +162,14 @@ const toItem = computed(() => {
|
||||
})
|
||||
|
||||
const pageSizeSelectOptions = computed(() => {
|
||||
return props.pageSizeOptions.map((size) => ({
|
||||
const options = Array.from(
|
||||
new Set([
|
||||
...getConfiguredTablePageSizeOptions(),
|
||||
normalizeTablePageSize(props.pageSize)
|
||||
])
|
||||
).sort((a, b) => a - b)
|
||||
|
||||
return options.map((size) => ({
|
||||
value: size,
|
||||
label: String(size)
|
||||
}))
|
||||
@@ -216,7 +224,7 @@ const goToPage = (newPage: number) => {
|
||||
|
||||
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||
if (value === null || typeof value === 'boolean') return
|
||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||
const newPageSize = normalizeTablePageSize(typeof value === 'string' ? parseInt(value, 10) : value)
|
||||
setPersistedPageSize(newPageSize)
|
||||
emit('update:pageSize', newPageSize)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 静默失败
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Global Table Preferences -->
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.site.tablePreferencesTitle') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.tablePreferencesDescription') }}
|
||||
</p>
|
||||
<div class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.site.tableDefaultPageSize') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.table_default_page_size"
|
||||
type="number"
|
||||
min="5"
|
||||
max="1000"
|
||||
step="1"
|
||||
class="input w-40"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.tableDefaultPageSizeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.site.tablePageSizeOptions') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="tablePageSizeOptionsInput"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.site.tablePageSizeOptionsPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.tablePageSizeOptionsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Endpoints -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
@@ -2125,6 +2167,7 @@ const smtpPasswordManuallyEdited = ref(false)
|
||||
const testEmailAddress = ref('')
|
||||
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
|
||||
const registrationEmailSuffixWhitelistDraft = ref('')
|
||||
const tablePageSizeOptionsInput = ref('10, 20, 50')
|
||||
|
||||
// Admin API Key 状态
|
||||
const adminApiKeyLoading = ref(true)
|
||||
@@ -2179,6 +2222,10 @@ const betaPolicyForm = reactive({
|
||||
}>
|
||||
})
|
||||
|
||||
const tablePageSizeMin = 5
|
||||
const tablePageSizeMax = 1000
|
||||
const tablePageSizeDefault = 20
|
||||
|
||||
interface DefaultSubscriptionGroupOption {
|
||||
value: number
|
||||
label: string
|
||||
@@ -2218,6 +2265,8 @@ const form = reactive<SettingsForm>({
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
table_default_page_size: tablePageSizeDefault,
|
||||
table_page_size_options: [10, 20, 50],
|
||||
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
|
||||
custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
|
||||
frontend_url: '',
|
||||
@@ -2402,6 +2451,35 @@ function removeEndpoint(index: number) {
|
||||
form.custom_endpoints.splice(index, 1)
|
||||
}
|
||||
|
||||
function formatTablePageSizeOptions(options: number[]): string {
|
||||
return options.join(', ')
|
||||
}
|
||||
|
||||
function parseTablePageSizeOptionsInput(raw: string): number[] | null {
|
||||
const tokens = raw
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0)
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = tokens.map((token) => Number(token))
|
||||
if (parsed.some((value) => !Number.isInteger(value))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const deduped = Array.from(new Set(parsed)).sort((a, b) => a - b)
|
||||
if (
|
||||
deduped.some((value) => value < tablePageSizeMin || value > tablePageSizeMax)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return deduped
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
@@ -2420,6 +2498,9 @@ async function loadSettings() {
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
settings.registration_email_suffix_whitelist
|
||||
)
|
||||
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
|
||||
Array.isArray(settings.table_page_size_options) ? settings.table_page_size_options : [10, 20, 50]
|
||||
)
|
||||
registrationEmailSuffixWhitelistDraft.value = ''
|
||||
form.smtp_password = ''
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
@@ -2465,6 +2546,37 @@ function removeDefaultSubscription(index: number) {
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
const normalizedTableDefaultPageSize = Math.floor(Number(form.table_default_page_size))
|
||||
if (
|
||||
!Number.isInteger(normalizedTableDefaultPageSize) ||
|
||||
normalizedTableDefaultPageSize < tablePageSizeMin ||
|
||||
normalizedTableDefaultPageSize > tablePageSizeMax
|
||||
) {
|
||||
appStore.showError(
|
||||
t('admin.settings.site.tableDefaultPageSizeRangeError', {
|
||||
min: tablePageSizeMin,
|
||||
max: tablePageSizeMax
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedTablePageSizeOptions = parseTablePageSizeOptionsInput(
|
||||
tablePageSizeOptionsInput.value
|
||||
)
|
||||
if (!normalizedTablePageSizeOptions) {
|
||||
appStore.showError(
|
||||
t('admin.settings.site.tablePageSizeOptionsFormatError', {
|
||||
min: tablePageSizeMin,
|
||||
max: tablePageSizeMax
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
form.table_default_page_size = normalizedTableDefaultPageSize
|
||||
form.table_page_size_options = normalizedTablePageSizeOptions
|
||||
|
||||
const normalizedDefaultSubscriptions = form.default_subscriptions
|
||||
.filter((item) => item.group_id > 0 && item.validity_days > 0)
|
||||
.map((item: DefaultSubscriptionSetting) => ({
|
||||
@@ -2542,6 +2654,8 @@ async function saveSettings() {
|
||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
||||
purchase_subscription_url: form.purchase_subscription_url,
|
||||
table_default_page_size: form.table_default_page_size,
|
||||
table_page_size_options: form.table_page_size_options,
|
||||
custom_menu_items: form.custom_menu_items,
|
||||
custom_endpoints: form.custom_endpoints,
|
||||
frontend_url: form.frontend_url,
|
||||
@@ -2578,6 +2692,9 @@ async function saveSettings() {
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist
|
||||
)
|
||||
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
|
||||
Array.isArray(updated.table_page_size_options) ? updated.table_page_size_options : [10, 20, 50]
|
||||
)
|
||||
registrationEmailSuffixWhitelistDraft.value = ''
|
||||
form.smtp_password = ''
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
|
||||
Reference in New Issue
Block a user