mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
feat(providers): add persistent card view and shared drag ordering
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<Card
|
||||
variant="interactive"
|
||||
class="flex min-w-0 flex-col cursor-pointer overflow-hidden"
|
||||
@mousedown="$emit('mousedown', $event)"
|
||||
@click="$emit('rowClick', $event, provider.id)"
|
||||
>
|
||||
<div class="flex items-start gap-2 p-4 pb-3">
|
||||
<slot name="drag-handle" />
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-base font-semibold"
|
||||
:class="provider.is_active ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ provider.name.slice(0, 1).toUpperCase() }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 truncate rounded text-left text-sm font-semibold text-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="provider.name"
|
||||
@click.stop="$emit('viewDetail', provider.id)"
|
||||
>
|
||||
{{ provider.name }}
|
||||
</button>
|
||||
<a
|
||||
v-if="safeProviderWebsite"
|
||||
:href="safeProviderWebsite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 text-muted-foreground transition-colors hover:text-primary"
|
||||
:title="safeProviderWebsite"
|
||||
@click.stop
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
v-if="editingDescriptionId === provider.id"
|
||||
data-desc-editor
|
||||
class="flex items-center gap-1"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="localDescriptionValue"
|
||||
v-auto-focus
|
||||
class="min-w-0 flex-1 rounded border border-border bg-background px-1.5 py-0.5 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
:placeholder="legacyT('输入备注...')"
|
||||
:aria-label="legacyT('输入备注...')"
|
||||
@keydown="handleDescriptionKeydown"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 text-primary hover:bg-muted"
|
||||
:title="legacyT('保存')"
|
||||
:aria-label="legacyT('保存')"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted"
|
||||
:title="legacyT('取消')"
|
||||
:aria-label="legacyT('取消')"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="group/desc flex max-w-full items-center gap-1 rounded text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="provider.description || legacyT('添加备注')"
|
||||
@click="handleStartEdit"
|
||||
>
|
||||
<span class="truncate">{{ provider.description || legacyT('添加备注') }}</span>
|
||||
<Pencil class="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover/desc:opacity-60 group-focus-visible/desc:opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||
class="shrink-0 text-xs"
|
||||
>
|
||||
{{ legacyT(provider.is_active ? '活跃' : '停用') }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4 px-4 pb-4">
|
||||
<div class="space-y-2 rounded-xl border border-border/40 bg-muted/20 p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ legacyT('余额监控') }}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="border-border/50 text-[10px] font-normal"
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<ProviderBalanceCell
|
||||
:provider="provider"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-3 divide-x divide-border/50 text-center">
|
||||
<div class="min-w-0 space-y-1 px-1">
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ legacyT('端点') }}
|
||||
</dt>
|
||||
<dd class="text-sm font-semibold tabular-nums">
|
||||
{{ provider.active_endpoints }}<span class="ml-0.5 text-xs font-normal text-muted-foreground">/ {{ provider.total_endpoints }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1 px-1">
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ legacyT(isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号') }}
|
||||
</dt>
|
||||
<dd class="text-sm font-semibold tabular-nums">
|
||||
{{ provider.active_keys }}<span class="ml-0.5 text-xs font-normal text-muted-foreground">/ {{ provider.total_keys }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1 px-1">
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ legacyT('模型') }}
|
||||
</dt>
|
||||
<dd class="text-sm font-semibold tabular-nums">
|
||||
{{ provider.active_models }}<span class="ml-0.5 text-xs font-normal text-muted-foreground">/ {{ provider.total_models }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-auto space-y-2 border-t border-border/40 pt-3">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ legacyT('端点健康') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="provider.endpoint_health_details?.length"
|
||||
class="grid grid-cols-3 gap-x-3 gap-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
|
||||
:key="endpoint.api_format"
|
||||
class="flex min-w-0 flex-col gap-1.5"
|
||||
:title="getEndpointTooltip(endpoint, locale)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-1 text-[10px] leading-none text-muted-foreground">
|
||||
<span class="font-medium">{{ formatApiFormatShort(endpoint.api_format) }}</span>
|
||||
<span class="tabular-nums">{{ getEndpointHealthLabel(endpoint) }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-border dark:bg-border/80">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="getEndpointDotColor(endpoint)"
|
||||
:style="{ width: getEndpointHealthBarWidth(endpoint) }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground/60"
|
||||
>{{ legacyT('暂无端点') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-t border-border/40 bg-muted/10 px-3 py-2"
|
||||
@click.stop
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||
:title="legacyT('查看详情')"
|
||||
:aria-label="legacyT('查看详情')"
|
||||
@click="$emit('viewDetail', provider.id)"
|
||||
>
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
:title="legacyT('编辑提供商')"
|
||||
:aria-label="legacyT('编辑提供商')"
|
||||
@click="$emit('editProvider', provider)"
|
||||
>
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
:title="legacyT('扩展操作配置')"
|
||||
:aria-label="legacyT('扩展操作配置')"
|
||||
@click="$emit('openOpsConfig', provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
:title="legacyT(provider.is_active ? '停用提供商' : '启用提供商')"
|
||||
:aria-label="legacyT(provider.is_active ? '停用提供商' : '启用提供商')"
|
||||
@click="$emit('toggleStatus', provider)"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
:title="legacyT('删除提供商')"
|
||||
:aria-label="legacyT('删除提供商')"
|
||||
@click="$emit('deleteProvider', provider)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Check, Edit, ExternalLink, Eye, KeyRound, Pencil, Power, Trash2, X } from 'lucide-vue-next'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import ProviderBalanceCell from './ProviderBalanceCell.vue'
|
||||
import { formatApiFormatShort, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
import {
|
||||
sortEndpoints,
|
||||
getEndpointHealthLabel,
|
||||
getEndpointHealthBarWidth,
|
||||
getEndpointDotColor,
|
||||
getEndpointTooltip,
|
||||
} from '@/features/providers/composables/useEndpointStatus'
|
||||
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
editingDescriptionId: string | null
|
||||
isBalanceLoading: (providerId: string) => boolean
|
||||
getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null
|
||||
getProviderBalanceBreakdown: (providerId: string) => { balance: number; points: number; currency: string } | null
|
||||
getProviderBalanceError: (providerId: string) => { status: string; message: string } | null
|
||||
getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null
|
||||
getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null
|
||||
getProviderBalanceExtra: (providerId: string, architectureId?: string) => BalanceExtraItem[]
|
||||
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
|
||||
formatResetCountdown: (resetsAt: number) => string
|
||||
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'mousedown': [event: MouseEvent]
|
||||
'rowClick': [event: MouseEvent, providerId: string]
|
||||
'viewDetail': [providerId: string]
|
||||
'editProvider': [provider: ProviderWithEndpointsSummary]
|
||||
'openOpsConfig': [provider: ProviderWithEndpointsSummary]
|
||||
'toggleStatus': [provider: ProviderWithEndpointsSummary]
|
||||
'deleteProvider': [provider: ProviderWithEndpointsSummary]
|
||||
'startEditDescription': [event: Event, provider: ProviderWithEndpointsSummary]
|
||||
'saveDescription': [event: Event, provider: ProviderWithEndpointsSummary, value: string]
|
||||
'cancelEditDescription': [event?: Event]
|
||||
}>()
|
||||
|
||||
const { legacyT, locale } = useI18n()
|
||||
const safeProviderWebsite = computed(() => safeExternalWebUrl(props.provider.website))
|
||||
const localDescriptionValue = ref('')
|
||||
const vAutoFocus = {
|
||||
mounted: (element: HTMLElement) => element.focus(),
|
||||
}
|
||||
|
||||
watch(() => props.editingDescriptionId, (providerId) => {
|
||||
if (providerId === props.provider.id) {
|
||||
localDescriptionValue.value = props.provider.description || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function handleStartEdit(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('startEditDescription', event, props.provider)
|
||||
}
|
||||
|
||||
function handleSave(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('saveDescription', event, props.provider, localDescriptionValue.value)
|
||||
}
|
||||
|
||||
function handleCancel(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('cancelEditDescription', event)
|
||||
}
|
||||
|
||||
function handleDescriptionKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleSave(event)
|
||||
} else if (event.key === 'Escape') {
|
||||
handleCancel(event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-5 touch-none select-none cursor-grab text-muted-foreground/50 hover:text-primary active:cursor-grabbing disabled:cursor-default"
|
||||
:disabled="disabled"
|
||||
:title="legacyT('拖动调整当前页展示顺序,也可使用方向键移动')"
|
||||
:aria-label="`${legacyT('调整展示顺序')}: ${providerName}`"
|
||||
data-provider-drag-handle
|
||||
@pointerdown.stop="$emit('pointerdown', $event)"
|
||||
@keydown.stop="$emit('keydown', $event)"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
@dragstart.prevent
|
||||
>
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GripVertical } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
providerName: string
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
pointerdown: [event: PointerEvent]
|
||||
keydown: [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@
|
||||
>
|
||||
<!-- 第一行:名称 + 状态 + 操作 -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<slot name="drag-handle" />
|
||||
<div class="flex-1 min-w-0 space-y-0.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-foreground truncate">{{ provider.name }}</span>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<div class="xl:hidden">
|
||||
<div :class="{ 'xl:hidden': !cardView }">
|
||||
<Select
|
||||
:model-value="filterStatus"
|
||||
@update:model-value="$emit('update:filterStatus', $event)"
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
|
||||
<!-- API 格式筛选 -->
|
||||
<div class="xl:hidden">
|
||||
<div :class="{ 'xl:hidden': !cardView }">
|
||||
<Select
|
||||
:model-value="filterApiFormat"
|
||||
@update:model-value="$emit('update:filterApiFormat', $event)"
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 模型筛选 -->
|
||||
<div class="xl:hidden">
|
||||
<div :class="{ 'xl:hidden': !cardView }">
|
||||
<Select
|
||||
:model-value="filterModel"
|
||||
@update:model-value="$emit('update:filterModel', $event)"
|
||||
@@ -122,13 +122,32 @@
|
||||
:loading="loading"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="{ 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary': cardView }"
|
||||
:title="legacyT(cardView ? '切换到列表视图' : '切换到卡片视图')"
|
||||
:aria-label="legacyT('卡片视图')"
|
||||
:aria-pressed="cardView"
|
||||
@click="$emit('toggleView')"
|
||||
>
|
||||
<List
|
||||
v-if="cardView"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<LayoutGrid
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search, Plus, FilterX, Users } from 'lucide-vue-next'
|
||||
import { Search, Plus, FilterX, Users, LayoutGrid, List } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
@@ -150,6 +169,7 @@ defineProps<{
|
||||
modelFilters: FilterOption[]
|
||||
hasActiveFilters: boolean
|
||||
loading: boolean
|
||||
cardView: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -161,6 +181,7 @@ defineEmits<{
|
||||
'batchProcess': []
|
||||
'addProvider': []
|
||||
'refresh': []
|
||||
'toggleView': []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
@mousedown="$emit('mousedown', $event)"
|
||||
@click="$emit('rowClick', $event, provider.id)"
|
||||
>
|
||||
<TableCell
|
||||
v-if="$slots['drag-handle']"
|
||||
class="w-9 px-2 py-3.5"
|
||||
@click.stop
|
||||
>
|
||||
<slot name="drag-handle" />
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { createI18n } from '@/i18n'
|
||||
import ProviderTableRow from '../ProviderTableRow.vue'
|
||||
import ProviderMobileCard from '../ProviderMobileCard.vue'
|
||||
import ProviderCard from '../ProviderCard.vue'
|
||||
|
||||
vi.mock('../ProviderBalanceCell.vue', () => ({
|
||||
default: { render: () => null },
|
||||
@@ -74,6 +75,7 @@ function mountProvider(component: Component, healthScore: number | null) {
|
||||
describe.each([
|
||||
['desktop provider row', ProviderTableRow],
|
||||
['mobile provider card', ProviderMobileCard],
|
||||
['grid provider card', ProviderCard],
|
||||
] as const)('%s endpoint health', (_name, component) => {
|
||||
it.each([
|
||||
{ score: null, label: '-', width: '100%', color: 'bg-muted-foreground/40' },
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { computed, nextTick, onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||
import { useEventListener, useLocalStorage, useRafFn } from '@vueuse/core'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface SortableProvider {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProviderPointerDrag {
|
||||
providerId: string
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
handle: HTMLElement
|
||||
scrollContainer: HTMLElement | null
|
||||
}
|
||||
|
||||
export function useProviderDisplayOrder<Provider extends SortableProvider>(
|
||||
providers: () => Provider[],
|
||||
container: Ref<HTMLElement | null>,
|
||||
) {
|
||||
const { legacyT } = useI18n()
|
||||
const savedOrder = useLocalStorage<string[]>('aether-provider-display-order', [])
|
||||
const knownOrder = ref<string[]>([])
|
||||
const draggingProviderId = ref<string | null>(null)
|
||||
const dropTargetId = ref<string | null>(null)
|
||||
const pointerPosition = ref({ clientX: 0, clientY: 0 })
|
||||
const announcement = ref('')
|
||||
let pointerDrag: ProviderPointerDrag | null = null
|
||||
let suppressClickUntil = 0
|
||||
|
||||
const normalizedOrder = computed(() => Array.isArray(savedOrder.value)
|
||||
? [...new Set(savedOrder.value.filter((providerId): providerId is string => typeof providerId === 'string'))]
|
||||
: [])
|
||||
|
||||
const orderedProviders = computed(() => {
|
||||
const ranks = new Map(normalizedOrder.value.map((providerId, index) => [providerId, index]))
|
||||
return [...providers()].sort((first, second) => (
|
||||
(ranks.get(first.id) ?? ranks.size) - (ranks.get(second.id) ?? ranks.size)
|
||||
))
|
||||
})
|
||||
|
||||
const draggingProvider = computed(() => orderedProviders.value.find(provider => provider.id === draggingProviderId.value))
|
||||
const dragPreviewStyle = computed(() => ({
|
||||
left: `${Math.max(8, Math.min(pointerPosition.value.clientX + 12, window.innerWidth - 208))}px`,
|
||||
top: `${Math.max(8, Math.min(pointerPosition.value.clientY + 12, window.innerHeight - 48))}px`,
|
||||
}))
|
||||
|
||||
function moveProvider(providerId: string, targetId: string) {
|
||||
const visibleIds = orderedProviders.value.map(provider => provider.id)
|
||||
const sourceIndex = visibleIds.indexOf(providerId)
|
||||
const targetIndex = visibleIds.indexOf(targetId)
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return
|
||||
|
||||
visibleIds.splice(sourceIndex, 1)
|
||||
visibleIds.splice(targetIndex, 0, providerId)
|
||||
const visibleSet = new Set(visibleIds)
|
||||
const allIds = [...new Set([...normalizedOrder.value, ...knownOrder.value, ...visibleIds])]
|
||||
let visibleIndex = 0
|
||||
savedOrder.value = allIds.map(currentId => visibleSet.has(currentId) ? visibleIds[visibleIndex++] ?? currentId : currentId)
|
||||
announcement.value = `${legacyT('展示顺序已更新')}: ${orderedProviders.value[targetIndex]?.name} (${targetIndex + 1}/${visibleIds.length})`
|
||||
}
|
||||
|
||||
function updateDropTarget() {
|
||||
const target = document.elementFromPoint(pointerPosition.value.clientX, pointerPosition.value.clientY)
|
||||
?.closest<HTMLElement>('[data-provider-sort-id]')
|
||||
const targetId = target?.dataset.providerSortId
|
||||
dropTargetId.value = target && container.value?.contains(target)
|
||||
&& targetId !== draggingProviderId.value
|
||||
&& orderedProviders.value.some(provider => provider.id === targetId)
|
||||
? targetId ?? null
|
||||
: null
|
||||
}
|
||||
|
||||
function findScrollContainer(handle: HTMLElement): HTMLElement | null {
|
||||
let ancestor = handle.parentElement
|
||||
while (ancestor && ancestor !== document.body) {
|
||||
if (/(auto|scroll)/.test(getComputedStyle(ancestor).overflowY) && ancestor.scrollHeight > ancestor.clientHeight) {
|
||||
return ancestor
|
||||
}
|
||||
ancestor = ancestor.parentElement
|
||||
}
|
||||
return document.scrollingElement as HTMLElement | null
|
||||
}
|
||||
|
||||
const { pause, resume } = useRafFn(() => {
|
||||
const scrollContainer = pointerDrag?.scrollContainer
|
||||
if (scrollContainer) {
|
||||
const bounds = scrollContainer.getBoundingClientRect()
|
||||
const isDocument = scrollContainer === document.scrollingElement
|
||||
const top = isDocument ? 0 : Math.max(0, bounds.top)
|
||||
const bottom = isDocument ? window.innerHeight : Math.min(window.innerHeight, bounds.bottom)
|
||||
const pointerY = pointerPosition.value.clientY
|
||||
const pointerX = pointerPosition.value.clientX
|
||||
if (isDocument || (pointerX >= bounds.left && pointerX <= bounds.right)) {
|
||||
if (pointerY < top + 48) {
|
||||
scrollContainer.scrollTop -= Math.min(12, (top + 48 - pointerY) / 4)
|
||||
} else if (pointerY > bottom - 48) {
|
||||
scrollContainer.scrollTop += Math.min(12, (pointerY - bottom + 48) / 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
updateDropTarget()
|
||||
}, { immediate: false })
|
||||
|
||||
function cancelDrag() {
|
||||
const previous = pointerDrag
|
||||
pointerDrag = null
|
||||
pause()
|
||||
if (draggingProviderId.value) suppressClickUntil = Date.now() + 250
|
||||
draggingProviderId.value = null
|
||||
dropTargetId.value = null
|
||||
if (previous?.handle.hasPointerCapture?.(previous.pointerId)) {
|
||||
previous.handle.releasePointerCapture(previous.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(providerId: string, event: PointerEvent) {
|
||||
if (event.button !== 0 || event.isPrimary === false || orderedProviders.value.length < 2) return
|
||||
if (!orderedProviders.value.some(provider => provider.id === providerId)) return
|
||||
const handle = event.currentTarget
|
||||
if (!(handle instanceof HTMLElement)) return
|
||||
|
||||
cancelDrag()
|
||||
event.preventDefault()
|
||||
handle.focus({ preventScroll: true })
|
||||
pointerDrag = {
|
||||
providerId,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
handle,
|
||||
scrollContainer: findScrollContainer(handle),
|
||||
}
|
||||
pointerPosition.value = { clientX: event.clientX, clientY: event.clientY }
|
||||
handle.setPointerCapture?.(event.pointerId)
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!pointerDrag || pointerDrag.pointerId !== event.pointerId) return
|
||||
pointerPosition.value = { clientX: event.clientX, clientY: event.clientY }
|
||||
if (!draggingProviderId.value) {
|
||||
if (Math.hypot(event.clientX - pointerDrag.startX, event.clientY - pointerDrag.startY) < 5) return
|
||||
draggingProviderId.value = pointerDrag.providerId
|
||||
resume()
|
||||
}
|
||||
event.preventDefault()
|
||||
updateDropTarget()
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
if (!pointerDrag || pointerDrag.pointerId !== event.pointerId) return
|
||||
const providerId = draggingProviderId.value
|
||||
if (providerId) {
|
||||
pointerPosition.value = { clientX: event.clientX, clientY: event.clientY }
|
||||
updateDropTarget()
|
||||
}
|
||||
const targetId = dropTargetId.value
|
||||
cancelDrag()
|
||||
if (providerId && targetId) moveProvider(providerId, targetId)
|
||||
}
|
||||
|
||||
function handleSortKeydown(providerId: string, event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
cancelDrag()
|
||||
return
|
||||
}
|
||||
const directions: Record<string, number> = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }
|
||||
const direction = directions[event.key]
|
||||
if (direction === undefined || pointerDrag) return
|
||||
event.preventDefault()
|
||||
const index = orderedProviders.value.findIndex(provider => provider.id === providerId)
|
||||
const target = orderedProviders.value[index + direction]
|
||||
if (index < 0 || !target) return
|
||||
const handle = event.currentTarget as HTMLElement
|
||||
moveProvider(providerId, target.id)
|
||||
void nextTick(() => handle.focus({ preventScroll: true }))
|
||||
}
|
||||
|
||||
function handleSortClick(event: MouseEvent) {
|
||||
if (!draggingProviderId.value && Date.now() >= suppressClickUntil) return
|
||||
suppressClickUntil = 0
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function sortItemClass(providerId: string) {
|
||||
return {
|
||||
'opacity-40': draggingProviderId.value === providerId,
|
||||
'ring-2 ring-inset ring-primary/60 bg-primary/5': dropTargetId.value === providerId,
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => providers().map(provider => provider.id), (providerIds) => {
|
||||
knownOrder.value = [...new Set([...knownOrder.value, ...providerIds])]
|
||||
cancelDrag()
|
||||
}, { immediate: true })
|
||||
useEventListener(window, 'pointermove', handlePointerMove, { passive: false })
|
||||
useEventListener(window, 'pointerup', handlePointerUp)
|
||||
useEventListener(window, 'pointercancel', (event) => {
|
||||
if (pointerDrag?.pointerId === event.pointerId) cancelDrag()
|
||||
})
|
||||
useEventListener(window, 'lostpointercapture', (event) => {
|
||||
if (pointerDrag?.pointerId === event.pointerId) cancelDrag()
|
||||
})
|
||||
useEventListener(window, 'blur', cancelDrag)
|
||||
useEventListener(window, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') cancelDrag()
|
||||
})
|
||||
onScopeDispose(cancelDrag)
|
||||
|
||||
return {
|
||||
orderedProviders,
|
||||
draggingProvider,
|
||||
dragPreviewStyle,
|
||||
announcement,
|
||||
startDrag,
|
||||
cancelDrag,
|
||||
handleSortKeydown,
|
||||
handleSortClick,
|
||||
sortItemClass,
|
||||
}
|
||||
}
|
||||
@@ -1734,6 +1734,12 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'启用提供商': 'Enable provider',
|
||||
'停用提供商': 'Disable provider',
|
||||
'扩展操作配置': 'Ops configuration',
|
||||
'卡片视图': 'Card view',
|
||||
'切换到卡片视图': 'Switch to card view',
|
||||
'切换到列表视图': 'Switch to list view',
|
||||
'拖动调整当前页展示顺序,也可使用方向键移动': 'Drag to reorder the current page, or use the arrow keys',
|
||||
'调整展示顺序': 'Adjust display order',
|
||||
'展示顺序已更新': 'Display order updated',
|
||||
'输入备注...': 'Enter notes...',
|
||||
'添加备注': 'Add notes',
|
||||
'配额': 'Quota',
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user