feat(providers): add persistent card view and shared drag ordering

This commit is contained in:
elky
2026-09-07 14:13:59 +08:00
parent a26680f460
commit aa7dbe67d3
10 changed files with 1280 additions and 9 deletions
+117 -5
View File
@@ -1,5 +1,10 @@
<template>
<div class="space-y-4">
<div
ref="providerListRef"
class="space-y-4"
:class="{ 'select-none [&_*]:!cursor-grabbing': draggingProvider }"
@click.capture="handleSortClick"
>
<ProviderDeleteProgressCard
:progress="providerDeleteProgress"
:stage-label="providerDeleteStageLabel"
@@ -25,6 +30,7 @@
:model-filters="modelFilters"
:has-active-filters="hasActiveFilters"
:loading="loading"
:card-view="cardView"
@update:search-query="searchQuery = $event"
@update:filter-status="filterStatus = $event"
@update:filter-api-format="filterApiFormat = $event"
@@ -33,6 +39,7 @@
@batch-process="openProviderBatchDialog"
@add-provider="openAddProviderDialog"
@refresh="loadProviders"
@toggle-view="cardView = !cardView"
/>
<!-- 加载状态 -->
@@ -54,6 +61,50 @@
/>
</div>
<div
v-else-if="cardView"
class="grid grid-cols-1 gap-4 p-4 sm:p-6 md:grid-cols-2 2xl:grid-cols-3"
>
<ProviderCard
v-for="provider in displayedProviders"
:key="provider.id"
:provider="provider"
:data-provider-sort-id="provider.id"
:class="sortItemClass(provider.id)"
:editing-description-id="editingDescriptionId"
:is-balance-loading="isBalanceLoading"
:get-provider-balance="getProviderBalance"
:get-provider-balance-breakdown="getProviderBalanceBreakdown"
:get-provider-balance-error="getProviderBalanceError"
:get-provider-checkin="getProviderCheckin"
:get-provider-cookie-expired="getProviderCookieExpired"
:get-provider-balance-extra="getProviderBalanceExtra"
:format-balance-display="formatBalanceDisplay"
:format-reset-countdown="formatResetCountdown"
:get-quota-used-color-class="getQuotaUsedColorClass"
@mousedown="handleMouseDown"
@row-click="handleRowClick"
@view-detail="openProviderDrawer"
@edit-provider="openEditProviderDialog"
@open-ops-config="openOpsConfigDialog"
@toggle-status="toggleProviderStatus"
@delete-provider="handleDeleteProvider"
@start-edit-description="startEditDescription"
@save-description="saveDescription"
@cancel-edit-description="cancelEditDescription"
>
<template #drag-handle>
<ProviderDragHandle
class="-ml-2 h-10 w-4"
:provider-name="provider.name"
:disabled="loading || displayedProviders.length < 2"
@pointerdown="startDrag(provider.id, $event)"
@keydown="handleSortKeydown(provider.id, $event)"
/>
</template>
</ProviderCard>
</div>
<!-- 桌面端表格 -->
<div
v-else
@@ -62,6 +113,9 @@
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-9 px-2">
<span class="sr-only">{{ legacyT('调整展示顺序') }}</span>
</TableHead>
<TableHead class="w-[18%] min-w-[140px]">
{{ legacyT('提供商信息') }}
</TableHead>
@@ -131,6 +185,8 @@
v-for="provider in displayedProviders"
:key="provider.id"
:provider="provider"
:data-provider-sort-id="provider.id"
:class="sortItemClass(provider.id)"
:editing-description-id="editingDescriptionId"
:is-balance-loading="isBalanceLoading"
:get-provider-balance="getProviderBalance"
@@ -152,20 +208,31 @@
@start-edit-description="startEditDescription"
@save-description="saveDescription"
@cancel-edit-description="cancelEditDescription"
/>
>
<template #drag-handle>
<ProviderDragHandle
:provider-name="provider.name"
:disabled="loading || displayedProviders.length < 2"
@pointerdown="startDrag(provider.id, $event)"
@keydown="handleSortKeydown(provider.id, $event)"
/>
</template>
</ProviderTableRow>
</TableBody>
</Table>
</div>
<!-- 移动端卡片列表 -->
<div
v-if="!loading && providers.length > 0"
v-if="!cardView && !loading && providers.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<ProviderMobileCard
v-for="provider in displayedProviders"
:key="provider.id"
:provider="provider"
:data-provider-sort-id="provider.id"
:class="sortItemClass(provider.id)"
:editing-description-id="editingDescriptionId"
:is-balance-loading="isBalanceLoading"
:get-provider-balance="getProviderBalance"
@@ -182,7 +249,17 @@
@start-edit-description="startEditDescription"
@save-description="saveDescription"
@cancel-edit-description="cancelEditDescription"
/>
>
<template #drag-handle>
<ProviderDragHandle
class="-ml-2 w-4"
:provider-name="provider.name"
:disabled="loading || displayedProviders.length < 2"
@pointerdown="startDrag(provider.id, $event)"
@keydown="handleSortKeydown(provider.id, $event)"
/>
</template>
</ProviderMobileCard>
</div>
<!-- 分页 -->
@@ -196,8 +273,25 @@
@update:page-size="pageSize = $event"
/>
</Card>
<span
class="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>{{ announcement }}</span>
</div>
<Teleport to="body">
<div
v-if="draggingProvider"
class="pointer-events-none fixed z-[100] w-[200px] truncate rounded-xl border border-primary/40 bg-card px-3 py-2 text-sm font-medium text-foreground shadow-lg"
:style="dragPreviewStyle"
aria-hidden="true"
>
{{ draggingProvider.name }}
</div>
</Teleport>
<!-- 对话框 -->
<ProviderFormDialog
v-model="providerDialogOpen"
@@ -234,6 +328,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import Card from '@/components/ui/card.vue'
import Table from '@/components/ui/table.vue'
import TableHeader from '@/components/ui/table-header.vue'
@@ -248,6 +343,8 @@ import ProviderBatchActionDialog from '@/features/providers/components/ProviderB
import ProviderTableHeader from '@/features/providers/components/ProviderTableHeader.vue'
import ProviderTableRow from '@/features/providers/components/ProviderTableRow.vue'
import ProviderMobileCard from '@/features/providers/components/ProviderMobileCard.vue'
import ProviderCard from '@/features/providers/components/ProviderCard.vue'
import ProviderDragHandle from '@/features/providers/components/ProviderDragHandle.vue'
import ProviderDeleteProgressCard from '@/features/providers/components/ProviderDeleteProgressCard.vue'
import ProviderEmptyState from '@/features/providers/components/ProviderEmptyState.vue'
import { useToast } from '@/composables/useToast'
@@ -255,6 +352,7 @@ import { useConfirm } from '@/composables/useConfirm'
import { useRowClick } from '@/composables/useRowClick'
import { useProviderFilters } from '@/features/providers/composables/useProviderFilters'
import { useProviderBalance } from '@/features/providers/composables/useProviderBalance'
import { useProviderDisplayOrder } from '@/features/providers/composables/useProviderDisplayOrder'
import {
getProvidersSummary,
getProvider,
@@ -294,6 +392,7 @@ function showLegacyError(err: unknown, fallback: string, title = '错误') {
// 状态
const loading = ref(false)
const cardView = useLocalStorage('aether-provider-card-view', false, { flush: 'sync' })
const providers = ref<ProviderWithEndpointsSummary[]>([])
let providersRequestId = 0
const providerDialogOpen = ref(false)
@@ -475,7 +574,20 @@ function sortProvidersByActiveAndPriority(items: ProviderWithEndpointsSummary[])
})
}
const displayedProviders = computed(() => sortProvidersByActiveAndPriority(providers.value))
const providerListRef = ref<HTMLElement | null>(null)
const {
orderedProviders: displayedProviders,
draggingProvider,
dragPreviewStyle,
announcement,
startDrag,
cancelDrag,
handleSortKeydown,
handleSortClick,
sortItemClass,
} = useProviderDisplayOrder(() => sortProvidersByActiveAndPriority(providers.value), providerListRef)
watch([loading, cardView, queryParams], cancelDrag)
function startEditDescription(_event: Event, provider: ProviderWithEndpointsSummary) {
editingDescriptionId.value = provider.id
@@ -0,0 +1,539 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { createI18n, setI18nLocale } from '@/i18n'
import ProviderManagement from '../ProviderManagement.vue'
const apiMocks = vi.hoisted(() => ({
getProvidersSummary: vi.fn(),
getGlobalModels: vi.fn(),
getProvider: vi.fn(),
updateProvider: vi.fn(),
}))
vi.mock('@/api/endpoints', async (importOriginal) => ({
...await importOriginal<typeof import('@/api/endpoints')>(),
...apiMocks,
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => ({ confirmDanger: vi.fn().mockResolvedValue(false) }),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }),
}))
vi.mock('@/features/providers/composables/useProviderBalance', () => ({
useProviderBalance: () => ({
loadArchitectureSchemas: vi.fn(),
loadBalances: vi.fn(),
getProviderBalance: () => ({ available: 125, currency: 'USD' }),
getProviderBalanceBreakdown: () => null,
getProviderBalanceError: () => null,
isBalanceLoading: () => false,
getProviderCheckin: () => null,
getProviderCookieExpired: () => null,
formatBalanceDisplay: () => '$125.00',
formatResetCountdown: () => '',
getProviderBalanceExtra: () => [],
getQuotaUsedColorClass: () => '',
startTick: vi.fn(),
stopTick: vi.fn(),
}),
}))
vi.mock('@/features/providers/components', () => ({
ProviderFormDialog: { render: () => null },
ProviderAuthDialog: { render: () => null },
}))
vi.mock('@/features/providers/components/ProviderBatchActionDialog.vue', () => ({
default: { render: () => null },
}))
vi.mock('@/features/providers/components/ProviderDetailDrawer.vue', async () => {
const { h } = await import('vue')
return {
__esModule: true,
default: {
props: ['open', 'providerId'],
setup: (props: { open: boolean; providerId: string }) => () => props.open
? h('div', { 'data-provider-detail': props.providerId })
: null,
},
}
})
function createProvider(overrides: Partial<ProviderWithEndpointsSummary> = {}): ProviderWithEndpointsSummary {
return {
id: 'provider-1',
name: 'Provider One',
description: 'Primary provider',
provider_type: 'custom',
provider_priority: 10,
keep_priority_on_conversion: false,
enable_format_conversion: true,
is_active: true,
total_endpoints: 2,
active_endpoints: 1,
total_keys: 3,
active_keys: 2,
total_models: 4,
active_models: 3,
global_model_ids: ['model-1'],
avg_health_score: 0.8,
unhealthy_endpoints: 0,
api_formats: ['openai:chat'],
endpoint_health_details: [{
api_format: 'openai:chat',
health_score: 0.8,
is_active: true,
total_keys: 3,
active_keys: 2,
}],
ops_configured: true,
created_at: '2026-09-07T00:00:00Z',
updated_at: '2026-09-07T00:00:00Z',
...overrides,
}
}
let mountedApp: App | null = null
let mountedRoot: HTMLElement | null = null
async function settle() {
for (let index = 0; index < 8; index += 1) {
await Promise.resolve()
await nextTick()
}
}
async function mountView() {
const root = document.createElement('div')
document.body.appendChild(root)
mountedRoot = root
mountedApp = createApp(ProviderManagement)
mountedApp.use(createI18n())
mountedApp.mount(root)
await settle()
return root
}
function unmountView() {
mountedApp?.unmount()
mountedRoot?.remove()
mountedApp = null
mountedRoot = null
}
function findButton(root: HTMLElement, title: string): HTMLButtonElement {
const button = root.querySelector<HTMLButtonElement>(`button[title="${title}"]`)
expect(button, `Missing button: ${title}`).not.toBeNull()
return button!
}
beforeEach(() => {
vi.clearAllMocks()
apiMocks.getProvidersSummary.mockResolvedValue({
items: [createProvider()],
total: 40,
})
apiMocks.getGlobalModels.mockResolvedValue({ models: [{ id: 'model-1', name: 'Model One' }] })
apiMocks.getProvider.mockResolvedValue(createProvider())
apiMocks.updateProvider.mockResolvedValue(createProvider({ is_active: false }))
})
const originalElementFromPoint = Object.getOwnPropertyDescriptor(document, 'elementFromPoint')
afterEach(() => {
unmountView()
if (originalElementFromPoint) {
Object.defineProperty(document, 'elementFromPoint', originalElementFromPoint)
} else {
Reflect.deleteProperty(document, 'elementFromPoint')
}
})
describe('ProviderManagement card view', () => {
it('places the view toggle immediately after refresh and switches layouts without reloading data', async () => {
const root = await mountView()
const toggle = findButton(root, '切换到卡片视图')
const filters = [...root.querySelectorAll('[role="combobox"]')].slice(0, 3)
expect(toggle.previousElementSibling).toBe(findButton(root, '刷新'))
expect(toggle.getAttribute('aria-pressed')).toBe('false')
expect(root.querySelector('table')).not.toBeNull()
expect(root.querySelector('dl')).toBeNull()
for (const filter of filters) {
expect(filter.closest('.xl\\:hidden')).not.toBeNull()
}
const requests = apiMocks.getProvidersSummary.mock.calls.length
toggle.click()
await settle()
expect(root.querySelector('table')).toBeNull()
expect(root.querySelectorAll('dl')).toHaveLength(1)
expect(root.textContent).toContain('$125.00')
expect(root.textContent).toContain('Primary provider')
expect(root.textContent).toContain('80%')
expect(toggle.getAttribute('aria-pressed')).toBe('true')
for (const filter of filters) {
expect(filter.closest('.xl\\:hidden')).toBeNull()
}
findButton(root, '切换到列表视图').click()
await settle()
expect(root.querySelector('table')).not.toBeNull()
expect(root.querySelector('dl')).toBeNull()
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(requests)
})
it('remembers the chosen layout across remounts', async () => {
let root = await mountView()
findButton(root, '切换到卡片视图').click()
await settle()
expect(localStorage.getItem('aether-provider-card-view')).toBe('true')
unmountView()
root = await mountView()
expect(root.querySelector('table')).toBeNull()
expect(findButton(root, '切换到列表视图').getAttribute('aria-pressed')).toBe('true')
findButton(root, '切换到列表视图').click()
await settle()
expect(localStorage.getItem('aether-provider-card-view')).toBe('false')
})
it.each([
{ name: 'cards', initial: false, selected: true, title: '切换到卡片视图' },
{ name: 'table', initial: true, selected: false, title: '切换到列表视图' },
])('immediately saves $name and restores it while reloading data', async ({ initial, selected, title }) => {
localStorage.setItem('aether-provider-card-view', String(initial))
let root = await mountView()
findButton(root, title).click()
expect(localStorage.getItem('aether-provider-card-view')).toBe(String(selected))
unmountView()
let finishLoading!: (value: { items: ProviderWithEndpointsSummary[]; total: number }) => void
apiMocks.getProvidersSummary.mockReturnValue(new Promise((resolve) => {
finishLoading = resolve
}))
root = await mountView()
const restoredTitle = selected ? '切换到列表视图' : '切换到卡片视图'
expect(findButton(root, restoredTitle).getAttribute('aria-pressed')).toBe(String(selected))
expect(findButton(root, '刷新').disabled).toBe(true)
finishLoading({ items: [createProvider()], total: 1 })
await settle()
expect(root.querySelector('table') === null).toBe(selected)
expect(root.querySelector('dl') !== null).toBe(selected)
expect(localStorage.getItem('aether-provider-card-view')).toBe(String(selected))
})
it('keeps the current search and page when switching views', async () => {
const root = await mountView()
const search = root.querySelector<HTMLInputElement>('#provider-search')!
search.value = 'Provider'
search.dispatchEvent(new Event('input', { bubbles: true }))
await vi.waitFor(() => {
expect(apiMocks.getProvidersSummary).toHaveBeenLastCalledWith(
expect.objectContaining({ search: 'Provider' }),
expect.any(Object),
)
})
const secondPage = [...root.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === '2')!
secondPage.click()
await settle()
const requests = apiMocks.getProvidersSummary.mock.calls.length
findButton(root, '切换到卡片视图').click()
await settle()
expect(search.value).toBe('Provider')
expect(root.querySelector('[aria-current="page"]')?.textContent?.trim()).toBe('2')
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(requests)
expect(apiMocks.getProvidersSummary).toHaveBeenLastCalledWith(
expect.objectContaining({ search: 'Provider', page: 2 }),
expect.any(Object),
)
})
it('supports note editing, status actions, and details from cards', async () => {
localStorage.setItem('aether-provider-card-view', 'true')
const root = await mountView()
findButton(root, 'Primary provider').click()
await settle()
const input = root.querySelector<HTMLInputElement>('[data-desc-editor] input')!
expect(input.value).toBe('Primary provider')
input.value = 'Updated note'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await settle()
expect(apiMocks.updateProvider).toHaveBeenCalledWith('provider-1', { description: 'Updated note' })
expect(root.textContent).toContain('Updated note')
expect(root.querySelector('[data-provider-detail]')).toBeNull()
findButton(root, '停用提供商').click()
await settle()
expect(apiMocks.updateProvider).toHaveBeenCalledWith('provider-1', { is_active: false })
expect(root.querySelector('[data-provider-detail]')).toBeNull()
findButton(root, 'Provider One').click()
await vi.waitFor(() => {
expect(root.querySelector('[data-provider-detail="provider-1"]')).not.toBeNull()
})
})
it('uses account labels and handles providers without endpoints', async () => {
localStorage.setItem('aether-provider-card-view', 'true')
apiMocks.getProvidersSummary.mockResolvedValue({
items: [createProvider({ provider_type: 'codex', is_active: false, endpoint_health_details: [] })],
total: 1,
})
const root = await mountView()
expect(root.querySelector('dl')?.textContent).toContain('账号')
expect(root.textContent).toContain('暂无端点')
expect(findButton(root, '启用提供商')).not.toBeNull()
})
it('does not display cards during loading or with an empty result', async () => {
localStorage.setItem('aether-provider-card-view', 'true')
let resolveRequest!: (value: { items: ProviderWithEndpointsSummary[]; total: number }) => void
apiMocks.getProvidersSummary.mockReturnValue(new Promise((resolve) => {
resolveRequest = resolve
}))
const root = await mountView()
expect(root.querySelector('dl')).toBeNull()
expect(findButton(root, '刷新').disabled).toBe(true)
resolveRequest({ items: [], total: 0 })
await settle()
expect(root.querySelector('dl')).toBeNull()
expect(root.textContent).toContain('暂无提供商,点击右上角添加')
expect(findButton(root, '刷新').disabled).toBe(false)
})
it('translates the view switch labels', async () => {
const root = await mountView()
setI18nLocale('en-US')
await settle()
expect(findButton(root, 'Switch to card view').getAttribute('aria-label')).toBe('Card view')
findButton(root, 'Switch to card view').click()
await settle()
expect(findButton(root, 'Switch to list view').getAttribute('aria-pressed')).toBe('true')
})
})
function mockSortableProviders() {
const providers = [1, 2, 3, 4].map(index => createProvider({
id: `provider-${index}`,
name: `Provider ${index}`,
provider_priority: index * 10,
}))
apiMocks.getProvidersSummary.mockResolvedValue({ items: providers, total: providers.length })
return providers
}
function providerElements(root: HTMLElement): HTMLElement[] {
const container = root.querySelector('table') ?? root
return [...container.querySelectorAll<HTMLElement>('[data-provider-sort-id]')]
}
function providerOrder(root: HTMLElement): string[] {
return providerElements(root).map(element => element.dataset.providerSortId!)
}
function pointerEvent(type: string, clientX: number, clientY: number, options: { button?: number; pointerType?: string } = {}) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY, button: options.button ?? 0 })
Object.defineProperties(event, {
pointerId: { value: 1 },
isPrimary: { value: true },
pointerType: { value: options.pointerType ?? 'mouse' },
})
return event
}
function startProviderDrag(root: HTMLElement, sourceId: string, targetId: string, pointerType = 'mouse') {
const elements = providerElements(root)
const handle = elements.find(element => element.dataset.providerSortId === sourceId)!
.querySelector<HTMLButtonElement>('[data-provider-drag-handle]')!
const target = elements.find(element => element.dataset.providerSortId === targetId)!
const hitTest = vi.fn((): Element | null => target)
Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: hitTest })
handle.dispatchEvent(pointerEvent('pointerdown', 40, 100, { pointerType }))
window.dispatchEvent(pointerEvent('pointermove', 100, 200, { pointerType }))
return { handle, target, hitTest }
}
async function dropProvider(handle: HTMLButtonElement) {
window.dispatchEvent(pointerEvent('pointerup', 100, 200))
handle.click()
await settle()
}
describe('ProviderManagement shared display order', () => {
it('drags table rows, synchronizes both card layouts, and leaves scheduling priorities unchanged', async () => {
const providers = mockSortableProviders()
const root = await mountView()
const { handle, target } = startProviderDrag(root, 'provider-1', 'provider-3')
await settle()
expect(target.classList.contains('ring-2')).toBe(true)
expect(providerElements(root)[0]?.classList.contains('opacity-40')).toBe(true)
await dropProvider(handle)
const expected = ['provider-2', 'provider-3', 'provider-1', 'provider-4']
expect(providerOrder(root)).toEqual(expected)
const mobileOrder = [...root.querySelectorAll<HTMLElement>('[data-provider-sort-id]')]
.filter(element => !element.closest('table'))
.map(element => element.dataset.providerSortId)
expect(mobileOrder).toEqual(expected)
expect(root.querySelector('[data-provider-detail]')).toBeNull()
expect(apiMocks.updateProvider).not.toHaveBeenCalled()
expect(providers.map(provider => provider.provider_priority)).toEqual([10, 20, 30, 40])
findButton(root, '切换到卡片视图').click()
await settle()
expect(providerOrder(root)).toEqual(expected)
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
})
it('supports touch dragging on card headers and restores the order after refresh and remount', async () => {
mockSortableProviders()
localStorage.setItem('aether-provider-card-view', 'true')
let root = await mountView()
const { handle } = startProviderDrag(root, 'provider-4', 'provider-1', 'touch')
await dropProvider(handle)
const expected = ['provider-4', 'provider-1', 'provider-2', 'provider-3']
expect(providerOrder(root)).toEqual(expected)
expect(JSON.parse(localStorage.getItem('aether-provider-display-order')!)).toEqual(expected)
findButton(root, '刷新').click()
await settle()
expect(providerOrder(root)).toEqual(expected)
findButton(root, '切换到列表视图').click()
await settle()
expect(providerOrder(root)).toEqual(expected)
unmountView()
root = await mountView()
expect(providerOrder(root)).toEqual(expected)
})
it.each(['escape', 'pointercancel', 'outside'] as const)('cancels a drag without saving when cancelled by %s', async (reason) => {
mockSortableProviders()
const root = await mountView()
const original = providerOrder(root)
const { handle, hitTest } = startProviderDrag(root, 'provider-1', 'provider-3')
if (reason === 'escape') {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
} else if (reason === 'pointercancel') {
window.dispatchEvent(pointerEvent('pointercancel', 100, 200))
} else {
hitTest.mockReturnValue(null)
}
await dropProvider(handle)
expect(providerOrder(root)).toEqual(original)
expect(JSON.parse(localStorage.getItem('aether-provider-display-order')!)).toEqual([])
expect(root.querySelector('.opacity-40')).toBeNull()
expect(root.querySelector('[data-provider-detail]')).toBeNull()
})
it('does not reorder on a handle click or a secondary mouse button', async () => {
mockSortableProviders()
const root = await mountView()
const original = providerOrder(root)
const handle = providerElements(root)[0]!.querySelector<HTMLButtonElement>('[data-provider-drag-handle]')!
handle.dispatchEvent(pointerEvent('pointerdown', 40, 100))
window.dispatchEvent(pointerEvent('pointermove', 42, 101))
window.dispatchEvent(pointerEvent('pointerup', 42, 101))
handle.click()
handle.dispatchEvent(pointerEvent('pointerdown', 40, 100, { button: 2 }))
window.dispatchEvent(pointerEvent('pointermove', 100, 200))
window.dispatchEvent(pointerEvent('pointerup', 100, 200))
await settle()
expect(providerOrder(root)).toEqual(original)
expect(root.querySelector('[data-provider-detail]')).toBeNull()
})
it('moves only visible providers while preserving filtered-out positions', async () => {
const providers = mockSortableProviders()
const root = await mountView()
apiMocks.getProvidersSummary.mockResolvedValue({ items: [providers[0], providers[2]], total: 2 })
const search = root.querySelector<HTMLInputElement>('#provider-search')!
search.value = 'filtered'
search.dispatchEvent(new Event('input', { bubbles: true }))
await vi.waitFor(() => expect(providerOrder(root)).toEqual(['provider-1', 'provider-3']))
const { handle } = startProviderDrag(root, 'provider-1', 'provider-3')
await dropProvider(handle)
expect(providerOrder(root)).toEqual(['provider-3', 'provider-1'])
apiMocks.getProvidersSummary.mockResolvedValue({ items: providers, total: 4 })
findButton(root, '重置筛选').click()
await vi.waitFor(() => {
expect(providerOrder(root)).toEqual(['provider-3', 'provider-2', 'provider-1', 'provider-4'])
})
})
it('supports keyboard ordering and keeps focus on the moved handle', async () => {
mockSortableProviders()
const root = await mountView()
const handle = providerElements(root)[0]!.querySelector<HTMLButtonElement>('[data-provider-drag-handle]')!
handle.focus()
handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
await settle()
expect(providerOrder(root)).toEqual(['provider-2', 'provider-1', 'provider-3', 'provider-4'])
expect(document.activeElement).toBe(handle)
expect(root.querySelector('[role="status"]')?.textContent).toContain('展示顺序已更新')
expect(root.querySelector('[data-provider-detail]')).toBeNull()
handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true }))
await settle()
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,
}))
const root = await mountView()
const firstDrag = startProviderDrag(root, 'provider-1', 'provider-2')
await dropProvider(firstDrag.handle)
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'])
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(JSON.parse(localStorage.getItem('aether-provider-display-order')!))
.toEqual(['provider-2', 'provider-1', 'provider-4', 'provider-3'])
})
it('ignores stale IDs and appends providers that are not in the saved order', async () => {
mockSortableProviders()
localStorage.setItem('aether-provider-display-order', JSON.stringify(['deleted-provider', 'provider-3', 'provider-1']))
const root = await mountView()
expect(providerOrder(root)).toEqual(['provider-3', 'provider-1', 'provider-2', 'provider-4'])
})
})