From fe211fc5631e3fc3dc2eb59418eaa4658223f47d Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 9 Apr 2026 15:13:16 +0800 Subject: [PATCH 01/14] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E5=9C=A8=20?= =?UTF-8?q?macOS=20=E7=B3=BB=E7=BB=9F=E4=B8=8B=E6=95=B0=E6=8D=AE=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E6=A8=AA=E5=90=91=E6=BB=9A=E5=8A=A8=E6=9D=A1=E9=97=AA?= =?UTF-8?q?=E9=9A=90=E5=92=8C=E6=B6=88=E5=A4=B1=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题原因: 原本的 `style.css` 代码全局使用了 W3C 标准属性 (`scrollbar-width`)。而在 Chrome 121+ 以及 Safari 环境下,一旦匹配到 W3C 标准属性,浏览器就会放弃 WebKit 的定制样式,全面交由操作系统原生渲染。因为 macOS 原生的滚动条特性就是“不滚动时自动隐藏”,加之原本又配置了全局 hover 时才显色的透明度逻辑,最终导致在苹果系统下数据表常常无法明显看出横向滚动条。 修复方式: 1. 在 `style.css` 中增加 `@supports (-moz-appearance:none)`,将对全局 `scrollbar-width` 的干预严格隔离限制在 Firefox 浏览器内,防止误伤 Chrome 等 WebKit 系浏览器。 2. 移除旧版代码中对 `.table-wrapper` 直接定义的 `scrollbar-width` 和透明覆盖。 3. 在 `DataTable.vue` 内部,通过 `!important` 将 Webkit 专属定制外观(12px高度,实心圆角灰色轨道)的优先权推至最高,强制覆盖透明隐身规则。 4. 为底层 table 添加了 `min-w-max` 属性,强制阻止内容宽度受限于屏幕边界带来的收缩,充分保证合理超出范围而触发常驻横向溢出。 --- frontend/src/components/common/DataTable.vue | 61 +++++++++++++++++++- frontend/src/style.css | 57 +++++------------- 2 files changed, 75 insertions(+), 43 deletions(-) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 159fbd84..36c7e278 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -68,7 +68,7 @@ 'is-scrollable': isScrollable }" > - +
+ + diff --git a/frontend/src/style.css b/frontend/src/style.css index e36a3651..9ea1e01c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -16,20 +16,22 @@ @apply min-h-screen; } - /* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */ - * { - scrollbar-width: thin; - scrollbar-color: transparent transparent; - } + /* 自定义滚动条 - 仅针对 Firefox,避免 Chrome 取消 webkit 的全局定制 */ + @supports (-moz-appearance:none) { + * { + scrollbar-width: thin; + scrollbar-color: transparent transparent; + } - *:hover, - *:focus-within { - scrollbar-color: rgba(156, 163, 175, 0.5) transparent; - } + *:hover, + *:focus-within { + scrollbar-color: rgba(156, 163, 175, 0.5) transparent; + } - .dark *:hover, - .dark *:focus-within { - scrollbar-color: rgba(75, 85, 99, 0.5) transparent; + .dark *:hover, + .dark *:focus-within { + scrollbar-color: rgba(75, 85, 99, 0.5) transparent; + } } ::-webkit-scrollbar { @@ -58,36 +60,7 @@ @apply bg-primary-500/20 text-primary-900 dark:text-primary-100; } - /* - * 表格滚动容器:始终显示滚动条,不跟随全局悬停策略。 - * - * 浏览器兼容性说明: - * - Chrome 121+ 原生支持 scrollbar-color / scrollbar-width。 - * 一旦元素匹配了这两个标准属性,::-webkit-scrollbar-* 被完全忽略。 - * 全局 * { scrollbar-width: thin } 使所有元素都走标准属性, - * 因此 Chrome 121+ 只看 scrollbar-color。 - * - Chrome < 121 不认识标准属性,只看 ::-webkit-scrollbar-*, - * 所以保留 ::-webkit-scrollbar-thumb 作为回退。 - * - Firefox 始终只看 scrollbar-color / scrollbar-width。 - */ - .table-wrapper { - scrollbar-width: auto; - scrollbar-color: rgba(156, 163, 175, 0.7) transparent; - } - .dark .table-wrapper { - scrollbar-color: rgba(75, 85, 99, 0.7) transparent; - } - /* 旧版 Chrome (< 121) 兼容回退 */ - .table-wrapper::-webkit-scrollbar { - width: 10px; - height: 10px; - } - .table-wrapper::-webkit-scrollbar-thumb { - @apply rounded-full bg-gray-400/70; - } - .dark .table-wrapper::-webkit-scrollbar-thumb { - @apply rounded-full bg-gray-500/70; - } + } @layer components { From d8fa38d55a252155449bac62ee344124bc8ebf19 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 9 Apr 2026 18:14:28 +0800 Subject: [PATCH 02/14] =?UTF-8?q?fix(account):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=AE=A1=E7=90=86=E4=B8=AD=E7=9A=84=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/account/AccountTableFilters.vue | 2 +- .../__tests__/AccountTableFilters.spec.ts | 56 ------------------- 2 files changed, 1 insertion(+), 57 deletions(-) delete mode 100644 frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index 6b474183..b33dad84 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -27,7 +27,7 @@ const updatePrivacyMode = (value: string | number | boolean | null) => { emit('u const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) } const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }]) const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }]) -const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }]) +const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }]) const privacyOpts = computed(() => [ { value: '', label: t('admin.accounts.allPrivacyModes') }, { value: '__unset__', label: t('admin.accounts.privacyUnset') }, diff --git a/frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts b/frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts deleted file mode 100644 index 5a0044e5..00000000 --- a/frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' - -import AccountTableFilters from '../AccountTableFilters.vue' - -vi.mock('vue-i18n', async () => { - const actual = await vi.importActual('vue-i18n') - return { - ...actual, - useI18n: () => ({ - t: (key: string) => key - }) - } -}) - -describe('AccountTableFilters', () => { - it('renders privacy mode options and emits privacy_mode updates', async () => { - const wrapper = mount(AccountTableFilters, { - props: { - searchQuery: '', - filters: { - platform: '', - type: '', - status: '', - group: '', - privacy_mode: '' - }, - groups: [] - }, - global: { - stubs: { - SearchInput: { - template: '
' - }, - Select: { - props: ['modelValue', 'options'], - emits: ['update:modelValue', 'change'], - template: '
' - } - } - } - }) - - const selects = wrapper.findAll('.select-stub') - expect(selects).toHaveLength(5) - - const privacyOptions = JSON.parse(selects[3].attributes('data-options')) - expect(privacyOptions).toEqual([ - { value: '', label: 'admin.accounts.allPrivacyModes' }, - { value: '__unset__', label: 'admin.accounts.privacyUnset' }, - { value: 'training_off', label: 'Privacy' }, - { value: 'training_set_cf_blocked', label: 'CF' }, - { value: 'training_set_failed', label: 'Fail' } - ]) - }) -}) From ad80606a4483ac9fb11f9ccc76ff09b3590fc627 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 9 Apr 2026 18:14:28 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat(settings):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E8=A1=A8=E6=A0=BC=E5=88=86=E9=A1=B5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE,=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') }} +

+
+
+
+
- + @@ -62,7 +70,7 @@ diff --git a/frontend/src/components/admin/announcements/__tests__/AnnouncementReadStatusDialog.spec.ts b/frontend/src/components/admin/announcements/__tests__/AnnouncementReadStatusDialog.spec.ts new file mode 100644 index 00000000..26c87d73 --- /dev/null +++ b/frontend/src/components/admin/announcements/__tests__/AnnouncementReadStatusDialog.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +import AnnouncementReadStatusDialog from '../AnnouncementReadStatusDialog.vue' + +const { getReadStatus, showError } = vi.hoisted(() => ({ + getReadStatus: vi.fn(), + showError: vi.fn(), +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + announcements: { + getReadStatus, + }, + }, +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError, + }), +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/composables/usePersistedPageSize', () => ({ + getPersistedPageSize: () => 20, +})) + +const BaseDialogStub = { + props: ['show', 'title', 'width'], + emits: ['close'], + template: '
', +} + +describe('AnnouncementReadStatusDialog', () => { + beforeEach(() => { + getReadStatus.mockReset() + showError.mockReset() + vi.useFakeTimers() + }) + + it('closes by aborting active requests and clearing debounced reloads', async () => { + let activeSignal: AbortSignal | undefined + getReadStatus.mockImplementation(async (...args: any[]) => { + activeSignal = args[4]?.signal + return new Promise(() => {}) + }) + + const wrapper = mount(AnnouncementReadStatusDialog, { + props: { + show: false, + announcementId: 1, + }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + DataTable: true, + Pagination: true, + Icon: true, + }, + }, + }) + + await wrapper.setProps({ show: true }) + await flushPromises() + + expect(getReadStatus).toHaveBeenCalledTimes(1) + expect(activeSignal?.aborted).toBe(false) + + const setupState = (wrapper.vm as any).$?.setupState + setupState.search = 'alice' + setupState.handleSearch() + + setupState.handleClose() + await flushPromises() + + expect(activeSignal?.aborted).toBe(true) + expect(wrapper.emitted('close')).toHaveLength(1) + + vi.advanceTimersByTime(350) + await flushPromises() + + expect(getReadStatus).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue index cbd18af6..bf79bea2 100644 --- a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue +++ b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue @@ -196,7 +196,6 @@ :total="localEntries.length" :page="currentPage" :page-size="pageSize" - :page-size-options="[10, 20, 50]" @update:page="currentPage = $event" @update:pageSize="handlePageSizeChange" /> diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index 9bbdb380..f4494e69 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -1,7 +1,15 @@