From fae51a0ec860e2e60d031aa2e8db8d66414d7e2a Mon Sep 17 00:00:00 2001 From: hkxiaoyao Date: Wed, 30 Sep 2026 15:36:52 +0800 Subject: [PATCH] fix(frontend): preserve provider order across pagination --- .../composables/useProviderDisplayOrder.ts | 3 + .../src/views/admin/ProviderManagement.vue | 37 +++++++++- .../ProviderManagement.card-view.spec.ts | 71 +++++++++++++++---- 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/frontend/src/features/providers/composables/useProviderDisplayOrder.ts b/frontend/src/features/providers/composables/useProviderDisplayOrder.ts index b00451cd7..6f61c28e1 100644 --- a/frontend/src/features/providers/composables/useProviderDisplayOrder.ts +++ b/frontend/src/features/providers/composables/useProviderDisplayOrder.ts @@ -34,6 +34,8 @@ export function useProviderDisplayOrder( ? [...new Set(savedOrder.value.filter((providerId): providerId is string => typeof providerId === 'string'))] : []) + const hasCustomOrder = computed(() => normalizedOrder.value.length > 0) + const orderedProviders = computed(() => { const ranks = new Map(normalizedOrder.value.map((providerId, index) => [providerId, index])) return [...providers()].sort((first, second) => ( @@ -212,6 +214,7 @@ export function useProviderDisplayOrder( return { orderedProviders, + hasCustomOrder, draggingProvider, dragPreviewStyle, announcement, diff --git a/frontend/src/views/admin/ProviderManagement.vue b/frontend/src/views/admin/ProviderManagement.vue index 9f4ff6a8c..f250f66b2 100644 --- a/frontend/src/views/admin/ProviderManagement.vue +++ b/frontend/src/views/admin/ProviderManagement.vue @@ -576,7 +576,8 @@ function sortProvidersByActiveAndPriority(items: ProviderWithEndpointsSummary[]) const providerListRef = ref(null) const { - orderedProviders: displayedProviders, + orderedProviders, + hasCustomOrder, draggingProvider, dragPreviewStyle, announcement, @@ -587,6 +588,16 @@ const { sortItemClass, } = useProviderDisplayOrder(() => sortProvidersByActiveAndPriority(providers.value), providerListRef) +// 自定义展示顺序需要作用于整个列表,因此改为一次性拉取全量并在前端分页, +// 否则服务端分页切片会把拖到前面的供应商排除在当前页之外(换每页条数后顺序回退)。 +const DISPLAY_ORDER_FETCH_PAGE_SIZE = 10_000 +const localPaging = computed(() => hasCustomOrder.value) +const displayedProviders = computed(() => { + if (!localPaging.value) return orderedProviders.value + const start = (currentPage.value - 1) * pageSize.value + return orderedProviders.value.slice(start, start + pageSize.value) +}) + watch([loading, cardView, queryParams], cancelDrag) function startEditDescription(_event: Event, provider: ProviderWithEndpointsSummary) { @@ -645,7 +656,10 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) { const requestId = ++providersRequestId loading.value = true try { - const response = await getProvidersSummary(queryParams.value, { + const requestParams = localPaging.value + ? { ...queryParams.value, page: 1, page_size: DISPLAY_ORDER_FETCH_PAGE_SIZE } + : queryParams.value + const response = await getProvidersSummary(requestParams, { cacheTtlMs: options.cacheTtlMs ?? 0, }) if (requestId !== providersRequestId) return @@ -656,7 +670,8 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) { Object.assign(existing, item) return existing }) - total.value = response.total + // 前端分页模式下总数以实际拉取到的条目为准,避免切片越界 + total.value = localPaging.value ? response.items.length : response.total // 异步加载配置了 ops 的 provider 的余额数据 loadBalances(providers.value) } catch (err: unknown) { @@ -669,10 +684,26 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) { } } +// 首次建立自定义顺序时,当前页可能只是全量的一部分,需要补拉全量以支持前端分页 +watch(hasCustomOrder, (enabled) => { + if (enabled && providers.value.length < total.value) { + void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS }) + } +}) + // 分页/筛选/搜索变化时重新加载 let debounceTimer: ReturnType | null = null watch(queryParams, (newParams, oldParams) => { if (debounceTimer) clearTimeout(debounceTimer) + // 前端分页模式下翻页/换每页条数只影响本地切片,无需重新请求 + const isPagingOnly = (newParams.page !== oldParams?.page || newParams.page_size !== oldParams?.page_size) && + newParams.search === oldParams?.search && + newParams.status === oldParams?.status && + newParams.api_format === oldParams?.api_format && + newParams.model_id === oldParams?.model_id + if (localPaging.value && isPagingOnly) { + return + } // 搜索输入 debounce 300ms,其他变化立即执行 const isSearchOnly = newParams.search !== oldParams?.search && newParams.page === oldParams?.page && diff --git a/frontend/src/views/admin/__tests__/ProviderManagement.card-view.spec.ts b/frontend/src/views/admin/__tests__/ProviderManagement.card-view.spec.ts index 39a7c8800..57e0014d1 100644 --- a/frontend/src/views/admin/__tests__/ProviderManagement.card-view.spec.ts +++ b/frontend/src/views/admin/__tests__/ProviderManagement.card-view.spec.ts @@ -528,32 +528,45 @@ describe('ProviderManagement shared display order', () => { expect(providerOrder(root)).toEqual(['provider-1', 'provider-2', 'provider-3', 'provider-4']) }) - it('preserves saved ordering on other pages when reordering the current page', async () => { - const providers = mockSortableProviders() - apiMocks.getProvidersSummary.mockImplementation(async ({ page }: { page: number }) => ({ - items: page === 1 ? providers.slice(0, 2) : providers.slice(2), - total: 40, + it('keeps the saved ordering across local pages after reordering the current page', async () => { + const providers = Array.from({ length: 12 }, (_, index) => createProvider({ + id: `provider-${index + 1}`, + name: `Provider ${index + 1}`, + provider_priority: (index + 1) * 10, })) + apiMocks.getProvidersSummary.mockImplementation(async (query: { page?: number, page_size?: number } = {}) => { + const page = query.page ?? 1 + const pageSize = query.page_size ?? 20 + return { + items: providers.slice((page - 1) * pageSize, page * pageSize), + total: providers.length, + } + }) + localStorage.setItem('provider-management-page-size', '10') const root = await mountView() - const firstDrag = startProviderDrag(root, 'provider-1', 'provider-2') - await dropProvider(firstDrag.handle) + const firstDrag = startProviderDrag(root, 'provider-2', 'provider-1') + await dropProvider(firstDrag.handle) + expect(providerOrder(root).slice(0, 2)).toEqual(['provider-2', 'provider-1']) + + const requestsBeforePaging = apiMocks.getProvidersSummary.mock.calls.length const secondPage = [...root.querySelectorAll('button')] .find(button => button.textContent?.trim() === '2')! secondPage.click() await settle() - expect(providerOrder(root)).toEqual(['provider-3', 'provider-4']) - const secondDrag = startProviderDrag(root, 'provider-4', 'provider-3') - await dropProvider(secondDrag.handle) - expect(providerOrder(root)).toEqual(['provider-4', 'provider-3']) + expect(providerOrder(root)).toEqual(['provider-11', 'provider-12']) + expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(requestsBeforePaging) const firstPage = [...root.querySelectorAll('button')] .find(button => button.textContent?.trim() === '1')! firstPage.click() await settle() - expect(providerOrder(root)).toEqual(['provider-2', 'provider-1']) + expect(providerOrder(root).slice(0, 2)).toEqual(['provider-2', 'provider-1']) expect(JSON.parse(localStorage.getItem('aether-provider-display-order')!)) - .toEqual(['provider-2', 'provider-1', 'provider-4', 'provider-3']) + .toEqual([ + 'provider-2', 'provider-1', 'provider-3', 'provider-4', 'provider-5', 'provider-6', + 'provider-7', 'provider-8', 'provider-9', 'provider-10', 'provider-11', 'provider-12', + ]) }) it('ignores stale IDs and appends providers that are not in the saved order', async () => { @@ -562,4 +575,36 @@ describe('ProviderManagement shared display order', () => { const root = await mountView() expect(providerOrder(root)).toEqual(['provider-3', 'provider-1', 'provider-2', 'provider-4']) }) + + it('keeps the dragged provider first after switching to a smaller page size', async () => { + const providers = Array.from({ length: 12 }, (_, index) => createProvider({ + id: `provider-${index + 1}`, + name: `Provider ${index + 1}`, + provider_priority: (index + 1) * 10, + })) + apiMocks.getProvidersSummary.mockImplementation(async (query: { page?: number, page_size?: number } = {}) => { + const page = query.page ?? 1 + const pageSize = query.page_size ?? 20 + return { + items: providers.slice((page - 1) * pageSize, page * pageSize), + total: providers.length, + } + }) + + localStorage.setItem('provider-management-page-size', '50') + let root = await mountView() + const { handle } = startProviderDrag(root, 'provider-12', 'provider-1') + await dropProvider(handle) + expect(providerOrder(root)[0]).toBe('provider-12') + + unmountView() + localStorage.setItem('provider-management-page-size', '10') + root = await mountView() + + expect(providerOrder(root)).toEqual([ + 'provider-12', + 'provider-1', 'provider-2', 'provider-3', 'provider-4', 'provider-5', + 'provider-6', 'provider-7', 'provider-8', 'provider-9', + ]) + }) })