fix(frontend): preserve provider order across pagination

This commit is contained in:
hkxiaoyao
2026-09-30 16:43:50 +08:00
parent 54fbcc25a1
commit fae51a0ec8
3 changed files with 95 additions and 16 deletions
@@ -34,6 +34,8 @@ export function useProviderDisplayOrder<Provider extends SortableProvider>(
? [...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<Provider extends SortableProvider>(
return {
orderedProviders,
hasCustomOrder,
draggingProvider,
dragPreviewStyle,
announcement,
@@ -576,7 +576,8 @@ function sortProvidersByActiveAndPriority(items: ProviderWithEndpointsSummary[])
const providerListRef = ref<HTMLElement | null>(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<typeof setTimeout> | 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 &&
@@ -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<HTMLButtonElement>('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<HTMLButtonElement>('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',
])
})
})