refactor: remove provider card view and group descriptions

This commit is contained in:
elky
2026-10-07 01:10:57 +08:00
parent 466c7918a1
commit 310098a853
8 changed files with 34 additions and 605 deletions
@@ -1,333 +0,0 @@
<template>
<Card
variant="interactive"
class="flex max-h-96 w-full min-w-0 flex-col cursor-pointer overflow-hidden"
@mousedown="$emit('mousedown', $event)"
@click="$emit('rowClick', $event, provider.id)"
>
<div class="flex shrink-0 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
v-if="$slots.scheduling"
class="shrink-0 px-4 pb-3"
>
<slot name="scheduling" />
</div>
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain break-words 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 shrink-0 items-center justify-between gap-2 border-t border-border/40 bg-muted/10 px-3 py-2"
@click.stop
>
<div class="flex shrink-0 items-center gap-0.5">
<slot name="group-action" />
<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>
<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>
@@ -101,7 +101,7 @@
<div
v-if="draft"
ref="groupMetadata"
class="min-w-0 space-y-2 border-b border-border/50 p-3"
class="min-w-0 border-b border-border/50 p-3"
aria-label="分组信息"
:inert="busy"
>
@@ -126,16 +126,6 @@
/>
</label>
</div>
<label class="block min-w-0">
<span class="sr-only">描述</span>
<Input
v-model="draft.description"
size="sm"
aria-label="策略描述"
placeholder="描述这个分组的用途"
:disabled="busy"
/>
</label>
</div>
<RoutingSchedulingPolicyEditor
v-if="draft"
@@ -380,11 +370,6 @@
aria-label="新分组名称"
placeholder="例如:日常使用"
/></label>
<label class="block space-y-1.5 text-sm"><span>描述</span><Input
v-model="createForm.description"
aria-label="新分组描述"
placeholder="这个分组的用途"
/></label>
<label class="flex items-center justify-between gap-3 text-sm"><span>启用分组</span><Switch
v-model="createForm.enabled"
aria-label="启用新分组"
@@ -470,7 +455,6 @@ interface RoutingGroupDraft {
id?: string
version: number
name: string
description: string
enabled: boolean
is_system_default: boolean
config_json: RoutingGroupConfig
@@ -503,7 +487,7 @@ const deleting = ref(false)
const creating = ref(false)
const busy = computed(() => loading.value || saving.value || deleting.value || creating.value)
const createDialogOpen = ref(false)
const createForm = ref({ name: '', description: '', enabled: true })
const createForm = ref({ name: '', enabled: true })
const draftGeneration = ref(0)
const groupMetadata = ref<HTMLElement | null>(null)
const advancedOpen = ref(false)
@@ -547,7 +531,7 @@ function cloneConfig(config: RoutingGroupConfig): RoutingGroupConfig {
}
function draftSnapshotValue(value: RoutingGroupDraft): string {
return JSON.stringify({ name: value.name.trim(), description: value.description.trim() || null, enabled: value.enabled, is_system_default: value.is_system_default, config_json: cloneConfig(value.config_json) })
return JSON.stringify({ name: value.name.trim(), enabled: value.enabled, is_system_default: value.is_system_default, config_json: cloneConfig(value.config_json) })
}
function resetEditors(): void {
@@ -566,13 +550,13 @@ function selectGroup(group: RoutingGroupRecord, preserveSelection = false): void
initialSchedulingSelection.value = selection
? { id: selection.id, scope: selection.scope, modelNames: [...selection.modelNames] }
: null
draft.value = { id: group.id, version: group.version, name: group.name, description: group.description ?? '', enabled: group.enabled, is_system_default: group.is_system_default, config_json: cloneConfig(group.config_json) }
draft.value = { id: group.id, version: group.version, name: group.name, enabled: group.enabled, is_system_default: group.is_system_default, config_json: cloneConfig(group.config_json) }
savedDraftSnapshot.value = draftSnapshotValue(draft.value)
}
function openCreate(): void {
if (busy.value) return
createForm.value = { name: '', description: '', enabled: true }
createForm.value = { name: '', enabled: true }
createDialogOpen.value = true
}
function closeCreate(value: boolean): void { if (!busy.value) createDialogOpen.value = value }
@@ -731,7 +715,7 @@ async function saveDraft(): Promise<boolean> {
const targetGroupId = draft.value.id
const submittedGeneration = draftGeneration.value
const submittedSnapshot = draftSnapshotValue(draft.value)
const payload = { name, description: draft.value.description.trim() || null, enabled: draft.value.enabled, is_system_default: draft.value.is_system_default, expected_version: draft.value.version, config_json: cloneConfig(draft.value.config_json) }
const payload = { name, enabled: draft.value.enabled, is_system_default: draft.value.is_system_default, expected_version: draft.value.version, config_json: cloneConfig(draft.value.config_json) }
saving.value = true
try {
const saved = await updateRoutingGroup(targetGroupId, payload)
@@ -759,7 +743,7 @@ async function createGroup(): Promise<void> {
if (draftDirty.value && !await ensureSaved()) return
creating.value = true
try {
const saved = await createRoutingGroup({ name: createForm.value.name.trim(), description: createForm.value.description.trim() || null, enabled: createForm.value.enabled, is_system_default: groups.value.length === 0, sort_order: groups.value.length, config_json: createEmptyRoutingGroupConfig() })
const saved = await createRoutingGroup({ name: createForm.value.name.trim(), enabled: createForm.value.enabled, is_system_default: groups.value.length === 0, sort_order: groups.value.length, config_json: createEmptyRoutingGroupConfig() })
replaceGroup(saved, true)
createDialogOpen.value = false
internalNavigation = true
@@ -22,7 +22,7 @@
</div>
<!-- API 格式筛选 -->
<div :class="{ 'xl:hidden': !cardView }">
<div class="xl:hidden">
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
@@ -45,7 +45,7 @@
<!-- 模型筛选 -->
<div
v-if="showModelFilter !== false"
:class="{ 'xl:hidden': !cardView }"
class="xl:hidden"
>
<Select
:model-value="filterModel"
@@ -104,32 +104,13 @@
: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, LayoutGrid, List } from 'lucide-vue-next'
import { Search, Plus, FilterX, Users } 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,7 +131,6 @@ withDefaults(defineProps<{
modelFilters: FilterOption[]
hasActiveFilters: boolean
loading: boolean
cardView: boolean
}>(), { showModelFilter: true })
defineEmits<{
@@ -161,7 +141,6 @@ defineEmits<{
'batchProcess': []
'addProvider': []
'refresh': []
'toggleView': []
}>()
const { legacyT } = useI18n()
@@ -154,7 +154,6 @@ describe('ProviderSchedulingView workspace navigation', () => {
selector(root),
element(root, '[aria-label="策略名称"]'),
button(root, '启用策略'),
element(root, '[aria-label="策略描述"]'),
button(root, '查看示例提供商'),
button(root, '高级设置'),
button(root, '故障转移'),
@@ -5,7 +5,6 @@ import type { EndpointHealthDetail, ProviderWithEndpointsSummary } from '@/api/e
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 },
@@ -76,7 +75,6 @@ function mountProvider(component: Component, healthScore: number | null, overrid
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' },
-3
View File
@@ -1856,9 +1856,6 @@ 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',
@@ -36,14 +36,12 @@
:model-filters="[]"
:has-active-filters="hasActiveFilters"
:loading="loading"
:card-view="cardView"
@update:search-query="searchQuery = $event"
@update:filter-api-format="filterApiFormat = $event"
@reset-filters="resetFilters"
@batch-process="openProviderBatchDialog"
@add-provider="openAddProviderDialog"
@refresh="loadProviders"
@toggle-view="cardView = !cardView"
/>
<!-- 加载状态 -->
@@ -65,70 +63,6 @@
/>
</div>
<div
v-else-if="cardView"
class="grid grid-cols-[repeat(auto-fill,minmax(min(100%,22rem),1fr))] gap-4 p-4 sm:p-6"
>
<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 #scheduling>
<ProviderGroupControls
:provider-name="provider.name"
:priority="getGroupPriority(provider)"
:edit-context="priorityEditContext"
:enabled="isGroupEnabled(provider.id)"
:disabled="schedulingBusy"
:priority-disabled="priorityEditingDisabled"
@update:priority="setGroupPriority(provider.id, $event)"
/>
</template>
<template #group-action>
<ProviderGroupToggleButton
class="h-8 w-8"
:provider-name="provider.name"
:enabled="isGroupEnabled(provider.id)"
:disabled="schedulingBusy"
@update:enabled="setGroupEnabled(provider.id, $event)"
/>
</template>
<template #drag-handle>
<ProviderDragHandle
class="-ml-2 h-10 w-4"
:provider-name="provider.name"
:disabled="loading || priorityEditingDisabled || displayedProviders.length < 2"
@pointerdown="startDrag(provider.id, $event)"
@keydown="handleSortKeydown(provider.id, $event)"
/>
</template>
</ProviderCard>
</div>
<!-- 桌面端表格 -->
<div
v-else
@@ -247,7 +181,7 @@
<!-- 移动端卡片列表 -->
<div
v-if="!cardView && !loading && providers.length > 0"
v-if="!loading && displayedProviders.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<ProviderMobileCard
@@ -373,7 +307,6 @@
<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'
@@ -388,7 +321,6 @@ 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 ProviderGroupControls from '@/features/providers/components/ProviderGroupControls.vue'
import ProviderGroupToggleButton from '@/features/providers/components/ProviderGroupToggleButton.vue'
import ProviderPriorityInput from '@/features/providers/components/ProviderPriorityInput.vue'
@@ -477,7 +409,6 @@ 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)
@@ -727,7 +658,7 @@ watch(() => [schedulingContext.value.busy, selectedPolicy.value?.scope, selected
currentPage.value = 1
})
watch([loading, cardView, queryParams], cancelDrag)
watch([loading, queryParams], cancelDrag)
function startEditDescription(_event: Event, provider: ProviderWithEndpointsSummary) {
editingDescriptionId.value = provider.id
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, type App, type PropType } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { createI18n, setI18nLocale } from '@/i18n'
import { createI18n } from '@/i18n'
import ProviderManagement from '../ProviderManagement.vue'
import { createSchedulingPolicy, writeSchedulingPolicies } from '@/features/routing/utils/schedulingPolicies'
import { createEmptyRoutingGroupConfig, getDefaultModelPolicy, getModelPolicy, setModelProviderPriorityOverrides, type RoutingGroupConfig, type RoutingModelPolicy } from '@/features/routing/utils/routingPolicy'
@@ -298,114 +298,8 @@ afterEach(() => {
}
})
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"]')]
.filter(filter => filter.closest('.xl\\:hidden'))
expect(filters).toHaveLength(1)
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('fills available width with shared grid columns and keeps bounded card content scrollable', async () => {
apiMocks.getProvidersSummary.mockResolvedValue({
items: Array.from({ length: 5 }, (_, index) => createProvider({ id: `provider-${index + 1}` })),
total: 5,
})
localStorage.setItem('aether-provider-card-view', 'true')
const root = await mountView()
const card = root.querySelector<HTMLElement>('[data-provider-sort-id="provider-1"]')!
const lastCard = root.querySelector<HTMLElement>('[data-provider-sort-id="provider-5"]')!
const header = card.firstElementChild!
const content = card.querySelector('.overflow-y-auto')!
const actions = card.lastElementChild!
expect(Array.from(card.classList)).toEqual(expect.arrayContaining(['max-h-96', 'w-full']))
expect(card.classList.contains('max-w-sm')).toBe(false)
expect(card.classList.contains('flex-1')).toBe(false)
expect(lastCard.className).toBe(card.className)
expect(lastCard.parentElement).toBe(card.parentElement)
expect(card.parentElement?.classList.contains('grid-cols-[repeat(auto-fill,minmax(min(100%,22rem),1fr))]')).toBe(true)
expect(card.parentElement?.classList.contains('grid')).toBe(true)
expect(card.parentElement?.classList.contains('box-content')).toBe(false)
expect(card.parentElement?.classList.contains('items-start')).toBe(false)
expect(header.classList.contains('shrink-0')).toBe(true)
expect(Array.from(content.classList)).toEqual(expect.arrayContaining(['min-h-0', 'overflow-y-auto']))
expect(actions.classList.contains('shrink-0')).toBe(true)
expect(actions.contains(findButton(root, '查看详情'))).toBe(true)
})
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 layouts and filters locally', async () => {
describe('ProviderManagement provider directory', () => {
it('filters and paginates the current search locally', async () => {
apiMocks.getProvidersSummary.mockResolvedValue({ items: Array.from({ length: 30 }, (_, index) => createProvider({ id: `provider-${index + 1}` })), total: 30 })
const root = await mountView()
const search = root.querySelector<HTMLInputElement>('#provider-search')!
@@ -416,21 +310,19 @@ describe('ProviderManagement card view', () => {
secondPage.click()
await settle()
findButton(root, '切换到卡片视图').click()
await settle()
expect(search.value).toBe('Provider')
expect(root.querySelector('[aria-current="page"]')?.textContent?.trim()).toBe('2')
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
expect(apiMocks.getProvidersSummary).toHaveBeenCalledWith({ page: 1, page_size: 10000 }, expect.any(Object))
})
it('supports note editing, status actions, and details from cards', async () => {
localStorage.setItem('aether-provider-card-view', 'true')
it.each(['table', 'mobile'] as const)('supports note editing, status actions, and details from the %s list', async layout => {
const root = await mountView()
findButton(root, 'Primary provider').click()
const row = providerElements(root, layout)[0]!
row.querySelector<HTMLElement>('[title="Primary provider"]')!.click()
await settle()
const input = root.querySelector<HTMLInputElement>('[data-desc-editor] input')!
const input = row.querySelector<HTMLInputElement>('[data-desc-editor] input')!
expect(input.value).toBe('Primary provider')
input.value = 'Updated note'
input.dispatchEvent(new Event('input', { bubbles: true }))
@@ -441,57 +333,46 @@ describe('ProviderManagement card view', () => {
expect(root.textContent).toContain('Updated note')
expect(root.querySelector('[data-provider-detail]')).toBeNull()
findButton(root, '全局停用提供商').click()
findButton(row, '全局停用提供商').click()
await settle()
expect(apiMocks.updateProvider).toHaveBeenCalledWith('provider-1', { is_active: false })
expect(root.querySelector('[data-provider-detail]')).toBeNull()
findButton(root, 'Provider One').click()
findButton(row, '查看详情').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(providerElements(root)[0]?.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')
it('does not display provider rows during loading or with an empty result', async () => {
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(root.querySelector('[data-provider-sort-id]')).toBeNull()
expect(findButton(root, '刷新').disabled).toBe(true)
resolveRequest({ items: [], total: 0 })
await settle()
expect(root.querySelector('dl')).toBeNull()
expect(root.querySelector('[data-provider-sort-id]')).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')
})
})
describe('ProviderManagement group directory', () => {
@@ -504,10 +385,9 @@ describe('ProviderManagement group directory', () => {
expect(root.textContent).toContain('$125.00')
})
it.each(['table', 'mobile card', 'grid card'] as const)('keeps the %s group action before details and isolated from global provider state', async layout => {
it.each(['table', 'mobile list'] as const)('keeps the %s group action before details and isolated from global provider state', async layout => {
const providers = mockSortableProviders()
providers[3]!.is_active = false
localStorage.setItem('aether-provider-card-view', String(layout === 'grid card'))
workspace.groups['group-b'] = {
...setModelProviderPriorityOverrides(createEmptyRoutingGroupConfig(), '*', { 'provider-4': 0, 'provider-2': 1, 'provider-3': 2, 'provider-1': 3 }),
disabled_providers: ['provider-4'],
@@ -811,9 +691,9 @@ function mockSortableProviders() {
return providers
}
function providerElements(root: HTMLElement): HTMLElement[] {
const container = root.querySelector('table') ?? root
return [...container.querySelectorAll<HTMLElement>('[data-provider-sort-id]')]
function providerElements(root: HTMLElement, layout: 'table' | 'mobile' = 'table'): HTMLElement[] {
return [...root.querySelectorAll<HTMLElement>('[data-provider-sort-id]')]
.filter(element => layout === 'table' ? element.closest('table') : !element.closest('table'))
}
function providerOrder(root: HTMLElement): string[] {
@@ -830,8 +710,8 @@ function pointerEvent(type: string, clientX: number, clientY: number, options: {
return event
}
function startProviderDrag(root: HTMLElement, sourceId: string, targetId: string, pointerType = 'mouse') {
const elements = providerElements(root)
function startProviderDrag(root: HTMLElement, sourceId: string, targetId: string, pointerType = 'mouse', layout: 'table' | 'mobile' = 'table') {
const elements = providerElements(root, layout)
const handle = elements.find(element => element.dataset.providerSortId === sourceId)!
.querySelector<HTMLButtonElement>('[data-provider-drag-handle]')!
const target = elements.find(element => element.dataset.providerSortId === targetId)!
@@ -849,7 +729,7 @@ async function dropProvider(handle: HTMLButtonElement) {
}
describe('ProviderManagement group priority ordering', () => {
it('drags table rows, synchronizes both card layouts, and updates only the current group draft', async () => {
it('drags table rows, synchronizes the mobile list, and updates only the current group draft', async () => {
const providers = mockSortableProviders()
const root = await mountView()
const { handle, target } = startProviderDrag(root, 'provider-1', 'provider-3')
@@ -870,17 +750,14 @@ describe('ProviderManagement group priority ordering', () => {
expect(getModelPolicy(workspace.groups['group-a']!, '*').provider_priority_overrides).toEqual({ 'provider-2': 0, 'provider-3': 1, 'provider-1': 2, 'provider-4': 3 })
expect(workspace.updatePriorityPolicy).toHaveBeenCalledOnce()
findButton(root, '切换到卡片视图').click()
await settle()
expect(providerOrder(root)).toEqual(expected)
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(1)
})
it('supports touch dragging and retains the group draft across resource refresh and layout changes', async () => {
it('supports mobile touch dragging and retains the group draft across resource refresh and remounts', async () => {
mockSortableProviders()
localStorage.setItem('aether-provider-card-view', 'true')
let root = await mountView()
const { handle } = startProviderDrag(root, 'provider-4', 'provider-1', 'touch')
const { handle } = startProviderDrag(root, 'provider-4', 'provider-1', 'touch', 'mobile')
await dropProvider(handle)
const expected = ['provider-4', 'provider-1', 'provider-2', 'provider-3']
expect(providerOrder(root)).toEqual(expected)
@@ -889,9 +766,6 @@ describe('ProviderManagement group priority ordering', () => {
findButton(root, '刷新').click()
await settle()
expect(providerOrder(root)).toEqual(expected)
findButton(root, '切换到列表视图').click()
await settle()
expect(providerOrder(root)).toEqual(expected)
unmountView()
root = await mountView()