mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 02:47:45 +08:00
fix(frontend): preserve provider order across pagination
This commit is contained in:
@@ -34,6 +34,8 @@ export function useProviderDisplayOrder<Provider extends SortableProvider>(
|
|||||||
? [...new Set(savedOrder.value.filter((providerId): providerId is string => typeof providerId === 'string'))]
|
? [...new Set(savedOrder.value.filter((providerId): providerId is string => typeof providerId === 'string'))]
|
||||||
: [])
|
: [])
|
||||||
|
|
||||||
|
const hasCustomOrder = computed(() => normalizedOrder.value.length > 0)
|
||||||
|
|
||||||
const orderedProviders = computed(() => {
|
const orderedProviders = computed(() => {
|
||||||
const ranks = new Map(normalizedOrder.value.map((providerId, index) => [providerId, index]))
|
const ranks = new Map(normalizedOrder.value.map((providerId, index) => [providerId, index]))
|
||||||
return [...providers()].sort((first, second) => (
|
return [...providers()].sort((first, second) => (
|
||||||
@@ -212,6 +214,7 @@ export function useProviderDisplayOrder<Provider extends SortableProvider>(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
orderedProviders,
|
orderedProviders,
|
||||||
|
hasCustomOrder,
|
||||||
draggingProvider,
|
draggingProvider,
|
||||||
dragPreviewStyle,
|
dragPreviewStyle,
|
||||||
announcement,
|
announcement,
|
||||||
|
|||||||
@@ -576,7 +576,8 @@ function sortProvidersByActiveAndPriority(items: ProviderWithEndpointsSummary[])
|
|||||||
|
|
||||||
const providerListRef = ref<HTMLElement | null>(null)
|
const providerListRef = ref<HTMLElement | null>(null)
|
||||||
const {
|
const {
|
||||||
orderedProviders: displayedProviders,
|
orderedProviders,
|
||||||
|
hasCustomOrder,
|
||||||
draggingProvider,
|
draggingProvider,
|
||||||
dragPreviewStyle,
|
dragPreviewStyle,
|
||||||
announcement,
|
announcement,
|
||||||
@@ -587,6 +588,16 @@ const {
|
|||||||
sortItemClass,
|
sortItemClass,
|
||||||
} = useProviderDisplayOrder(() => sortProvidersByActiveAndPriority(providers.value), providerListRef)
|
} = 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)
|
watch([loading, cardView, queryParams], cancelDrag)
|
||||||
|
|
||||||
function startEditDescription(_event: Event, provider: ProviderWithEndpointsSummary) {
|
function startEditDescription(_event: Event, provider: ProviderWithEndpointsSummary) {
|
||||||
@@ -645,7 +656,10 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) {
|
|||||||
const requestId = ++providersRequestId
|
const requestId = ++providersRequestId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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,
|
cacheTtlMs: options.cacheTtlMs ?? 0,
|
||||||
})
|
})
|
||||||
if (requestId !== providersRequestId) return
|
if (requestId !== providersRequestId) return
|
||||||
@@ -656,7 +670,8 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) {
|
|||||||
Object.assign(existing, item)
|
Object.assign(existing, item)
|
||||||
return existing
|
return existing
|
||||||
})
|
})
|
||||||
total.value = response.total
|
// 前端分页模式下总数以实际拉取到的条目为准,避免切片越界
|
||||||
|
total.value = localPaging.value ? response.items.length : response.total
|
||||||
// 异步加载配置了 ops 的 provider 的余额数据
|
// 异步加载配置了 ops 的 provider 的余额数据
|
||||||
loadBalances(providers.value)
|
loadBalances(providers.value)
|
||||||
} catch (err: unknown) {
|
} 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
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
watch(queryParams, (newParams, oldParams) => {
|
watch(queryParams, (newParams, oldParams) => {
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
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,其他变化立即执行
|
// 搜索输入 debounce 300ms,其他变化立即执行
|
||||||
const isSearchOnly = newParams.search !== oldParams?.search &&
|
const isSearchOnly = newParams.search !== oldParams?.search &&
|
||||||
newParams.page === oldParams?.page &&
|
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'])
|
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 () => {
|
it('keeps the saved ordering across local pages after reordering the current page', async () => {
|
||||||
const providers = mockSortableProviders()
|
const providers = Array.from({ length: 12 }, (_, index) => createProvider({
|
||||||
apiMocks.getProvidersSummary.mockImplementation(async ({ page }: { page: number }) => ({
|
id: `provider-${index + 1}`,
|
||||||
items: page === 1 ? providers.slice(0, 2) : providers.slice(2),
|
name: `Provider ${index + 1}`,
|
||||||
total: 40,
|
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 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')]
|
const secondPage = [...root.querySelectorAll<HTMLButtonElement>('button')]
|
||||||
.find(button => button.textContent?.trim() === '2')!
|
.find(button => button.textContent?.trim() === '2')!
|
||||||
secondPage.click()
|
secondPage.click()
|
||||||
await settle()
|
await settle()
|
||||||
expect(providerOrder(root)).toEqual(['provider-3', 'provider-4'])
|
expect(providerOrder(root)).toEqual(['provider-11', 'provider-12'])
|
||||||
const secondDrag = startProviderDrag(root, 'provider-4', 'provider-3')
|
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(requestsBeforePaging)
|
||||||
await dropProvider(secondDrag.handle)
|
|
||||||
expect(providerOrder(root)).toEqual(['provider-4', 'provider-3'])
|
|
||||||
|
|
||||||
const firstPage = [...root.querySelectorAll<HTMLButtonElement>('button')]
|
const firstPage = [...root.querySelectorAll<HTMLButtonElement>('button')]
|
||||||
.find(button => button.textContent?.trim() === '1')!
|
.find(button => button.textContent?.trim() === '1')!
|
||||||
firstPage.click()
|
firstPage.click()
|
||||||
await settle()
|
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')!))
|
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 () => {
|
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()
|
const root = await mountView()
|
||||||
expect(providerOrder(root)).toEqual(['provider-3', 'provider-1', 'provider-2', 'provider-4'])
|
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',
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user