mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
998 lines
37 KiB
Vue
998 lines
37 KiB
Vue
<template>
|
|
<ProviderSchedulingView
|
|
ref="schedulingWorkspace"
|
|
:provider-revision="providerRevision"
|
|
@inspect-provider="openProviderDrawer"
|
|
@context-change="updateSchedulingContext"
|
|
>
|
|
<div
|
|
ref="providerListRef"
|
|
class="flex h-full min-w-0 flex-col gap-4"
|
|
:class="{ 'select-none [&_*]:!cursor-grabbing': draggingProvider }"
|
|
@click.capture="handleSortClick"
|
|
>
|
|
<ProviderDeleteProgressCard
|
|
:progress="providerDeleteProgress"
|
|
:stage-label="providerDeleteStageLabel"
|
|
:total-units="providerDeleteTotalUnits"
|
|
:completed-units="providerDeleteCompletedUnits"
|
|
:overall-percent="providerDeleteOverallPercent"
|
|
:keys-percent="providerDeleteKeysPercent"
|
|
:endpoints-percent="providerDeleteEndpointsPercent"
|
|
/>
|
|
|
|
<!-- 提供商表格 -->
|
|
<Card
|
|
variant="default"
|
|
class="flex-1"
|
|
>
|
|
<!-- 标题和操作栏 -->
|
|
<ProviderTableHeader
|
|
:search-query="searchQuery"
|
|
:filter-api-format="filterApiFormat"
|
|
filter-model="all"
|
|
:show-model-filter="false"
|
|
:api-format-filters="apiFormatFilters"
|
|
: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"
|
|
/>
|
|
|
|
<!-- 加载状态 -->
|
|
<div
|
|
v-if="loading"
|
|
class="flex items-center justify-center py-12"
|
|
>
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
</div>
|
|
|
|
<!-- 空状态 -->
|
|
<div
|
|
v-else-if="displayedProviders.length === 0"
|
|
class="contents"
|
|
>
|
|
<ProviderEmptyState
|
|
:has-active-filters="hasActiveFilters"
|
|
@reset-filters="resetFilters"
|
|
/>
|
|
</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
|
|
class="hidden xl:block overflow-x-auto"
|
|
>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead class="w-px px-2 text-center">
|
|
<span class="sr-only">{{ legacyT('优先级') }}</span>
|
|
</TableHead>
|
|
<TableHead class="w-[18%] min-w-[200px]">
|
|
{{ legacyT('提供商') }}
|
|
</TableHead>
|
|
<TableHead class="w-[20%] min-w-[180px]">
|
|
{{ legacyT('余额监控') }}
|
|
</TableHead>
|
|
<TableHead class="w-[12%] min-w-[100px] text-center">
|
|
{{ legacyT('资源统计') }}
|
|
</TableHead>
|
|
<SortableTableHead
|
|
class="w-[22%] min-w-[240px]"
|
|
column-key="api_format"
|
|
:sortable="false"
|
|
:filter-active="filterApiFormat !== 'all'"
|
|
:filter-title="legacyT('筛选 API 格式')"
|
|
filter-content-class="w-72 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
|
|
>
|
|
{{ legacyT('端点健康') }}
|
|
<template #filter="{ close }">
|
|
<TableFilterMenu
|
|
v-model="filterApiFormat"
|
|
:options="apiFormatFilters"
|
|
@select="close"
|
|
/>
|
|
</template>
|
|
</SortableTableHead>
|
|
<TableHead class="w-[10%] min-w-[96px] text-center">
|
|
{{ legacyT('状态') }}
|
|
</TableHead>
|
|
<TableHead class="w-[18%] min-w-[192px] text-center">
|
|
{{ legacyT('操作') }}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
<ProviderTableRow
|
|
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 #priority>
|
|
<div class="flex items-center gap-0.5">
|
|
<ProviderDragHandle
|
|
:provider-name="provider.name"
|
|
:disabled="loading || priorityEditingDisabled || displayedProviders.length < 2"
|
|
@pointerdown="startDrag(provider.id, $event)"
|
|
@keydown="handleSortKeydown(provider.id, $event)"
|
|
/>
|
|
<ProviderPriorityInput
|
|
:provider-name="provider.name"
|
|
:priority="getGroupPriority(provider)"
|
|
:edit-context="priorityEditContext"
|
|
:disabled="priorityEditingDisabled"
|
|
@update:priority="setGroupPriority(provider.id, $event)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<template #scheduling>
|
|
<ProviderGroupControls
|
|
:provider-name="provider.name"
|
|
:priority="getGroupPriority(provider)"
|
|
:edit-context="priorityEditContext"
|
|
:enabled="isGroupEnabled(provider.id)"
|
|
:disabled="schedulingBusy"
|
|
:priority-disabled="priorityEditingDisabled"
|
|
:show-priority="false"
|
|
@update:priority="setGroupPriority(provider.id, $event)"
|
|
/>
|
|
</template>
|
|
<template #group-action>
|
|
<ProviderGroupToggleButton
|
|
:provider-name="provider.name"
|
|
:enabled="isGroupEnabled(provider.id)"
|
|
:disabled="schedulingBusy"
|
|
@update:enabled="setGroupEnabled(provider.id, $event)"
|
|
/>
|
|
</template>
|
|
</ProviderTableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<!-- 移动端卡片列表 -->
|
|
<div
|
|
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"
|
|
:get-provider-balance-error="getProviderBalanceError"
|
|
:get-provider-checkin="getProviderCheckin"
|
|
:get-provider-cookie-expired="getProviderCookieExpired"
|
|
:format-balance-display="formatBalanceDisplay"
|
|
:get-quota-used-color-class="getQuotaUsedColorClass"
|
|
@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
|
|
:provider-name="provider.name"
|
|
:enabled="isGroupEnabled(provider.id)"
|
|
:disabled="schedulingBusy"
|
|
@update:enabled="setGroupEnabled(provider.id, $event)"
|
|
/>
|
|
</template>
|
|
<template #drag-handle>
|
|
<ProviderDragHandle
|
|
class="-ml-2 w-4"
|
|
:provider-name="provider.name"
|
|
:disabled="loading || priorityEditingDisabled || displayedProviders.length < 2"
|
|
@pointerdown="startDrag(provider.id, $event)"
|
|
@keydown="handleSortKeydown(provider.id, $event)"
|
|
/>
|
|
</template>
|
|
</ProviderMobileCard>
|
|
</div>
|
|
|
|
<!-- 分页 -->
|
|
<Pagination
|
|
v-if="!loading && total > 0"
|
|
:current="currentPage"
|
|
:total="total"
|
|
:page-size="pageSize"
|
|
cache-key="provider-management-page-size"
|
|
@update:current="currentPage = $event"
|
|
@update:page-size="pageSize = $event"
|
|
/>
|
|
</Card>
|
|
<span
|
|
class="sr-only"
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
>{{ announcement }}</span>
|
|
</div>
|
|
</ProviderSchedulingView>
|
|
|
|
<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"
|
|
:provider="providerToEdit"
|
|
:max-priority="maxProviderPriority"
|
|
:routing-group-id="providerCreationGroup?.id"
|
|
:routing-group-name="providerCreationGroup?.name"
|
|
@provider-created="handleProviderAdded"
|
|
@provider-updated="handleProviderUpdated"
|
|
/>
|
|
|
|
<ProviderBatchActionDialog
|
|
v-model="providerBatchDialogOpen"
|
|
:providers="displayedProviders"
|
|
@changed="handleProviderBatchChanged"
|
|
/>
|
|
|
|
<ProviderDetailDrawer
|
|
v-if="providerDrawerMounted"
|
|
:open="providerDrawerOpen"
|
|
:provider-id="selectedProviderId"
|
|
:initial-provider="selectedProvider"
|
|
@update:open="providerDrawerOpen = $event"
|
|
@edit="openEditProviderDialog"
|
|
@toggle-status="toggleProviderStatus"
|
|
@refresh="handleDrawerRefresh"
|
|
/>
|
|
|
|
<ProviderAuthDialog
|
|
v-model:open="opsConfigDialogOpen"
|
|
:provider-id="opsConfigProviderId"
|
|
:provider-website="opsConfigProviderWebsite"
|
|
@saved="handleOpsConfigSaved"
|
|
/>
|
|
</template>
|
|
|
|
<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'
|
|
import TableBody from '@/components/ui/table-body.vue'
|
|
import TableRow from '@/components/ui/table-row.vue'
|
|
import TableHead from '@/components/ui/table-head.vue'
|
|
import SortableTableHead from '@/components/ui/sortable-table-head.vue'
|
|
import TableFilterMenu from '@/components/ui/table-filter-menu.vue'
|
|
import Pagination from '@/components/ui/pagination.vue'
|
|
import { ProviderFormDialog, ProviderAuthDialog } from '@/features/providers/components'
|
|
import ProviderBatchActionDialog from '@/features/providers/components/ProviderBatchActionDialog.vue'
|
|
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'
|
|
import { providerGroupPriority, sortGroupProviders, moveGroupProvider } from '@/features/providers/utils/groupPriority'
|
|
import { getDefaultModelPolicy, normalizeRoutingGroupConfig, type RoutingModelPolicy, type RoutingPriorityMode, type RoutingSchedulingMode, type RoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
|
|
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'
|
|
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 { useProviderPriorityOrder } from '@/features/providers/composables/useProviderPriorityOrder'
|
|
import {
|
|
getProvidersSummary,
|
|
getProvider,
|
|
deleteProvider,
|
|
getProviderDeleteTask,
|
|
updateProvider,
|
|
type ProviderWithEndpointsSummary,
|
|
} from '@/api/endpoints'
|
|
import { parseApiError } from '@/utils/errorParser'
|
|
import { useI18n } from '@/i18n'
|
|
|
|
const ProviderDetailDrawer = defineAsyncComponent(
|
|
() => import('@/features/providers/components/ProviderDetailDrawer.vue'),
|
|
)
|
|
const ProviderSchedulingView = defineAsyncComponent(
|
|
() => import('@/features/providers/components/ProviderSchedulingView.vue'),
|
|
)
|
|
|
|
interface SchedulingSelection {
|
|
policy: RoutingModelPolicy | null
|
|
priorityMode: RoutingPriorityMode
|
|
schedulingMode: RoutingSchedulingMode
|
|
scope: 'all' | 'selected' | null
|
|
modelNames: string[]
|
|
}
|
|
interface SchedulingContext {
|
|
groupId: string | null
|
|
groupName: string
|
|
config: RoutingGroupConfig | null
|
|
busy: boolean
|
|
activePolicy: SchedulingSelection | null
|
|
}
|
|
const schedulingContext = ref<SchedulingContext>({ groupId: null, groupName: '', config: null, busy: true, activePolicy: null })
|
|
const schedulingWorkspace = ref<{
|
|
updateDraftConfig: (config: RoutingGroupConfig) => void
|
|
updatePriorityPolicy: (policy: RoutingModelPolicy) => void
|
|
refreshGroups: () => Promise<void>
|
|
ensureSaved: () => Promise<boolean>
|
|
} | null>(null)
|
|
const schedulingBusy = computed(() => schedulingContext.value.busy || !schedulingContext.value.config)
|
|
const providerCreationGroup = ref<{ id: string; name: string } | null>(null)
|
|
const resourcesLoaded = ref(false)
|
|
const providerRevision = ref(0)
|
|
function updateSchedulingContext(context: SchedulingContext) {
|
|
if (context.groupId !== schedulingContext.value.groupId) {
|
|
currentPage.value = 1
|
|
cancelDrag()
|
|
}
|
|
schedulingContext.value = context
|
|
}
|
|
|
|
interface ProviderDeleteProgressState {
|
|
providerId: string
|
|
providerName: string
|
|
taskId: string
|
|
status: string
|
|
stage: string
|
|
totalKeys: number
|
|
deletedKeys: number
|
|
totalEndpoints: number
|
|
deletedEndpoints: number
|
|
message: string
|
|
}
|
|
|
|
const { error: showError, success: showSuccess, info: showInfo } = useToast()
|
|
const { confirmDanger } = useConfirm()
|
|
const { legacyT } = useI18n()
|
|
|
|
function showLegacyError(err: unknown, fallback: string, title = '错误') {
|
|
showError(legacyT(parseApiError(err, fallback)), legacyT(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)
|
|
const providerBatchDialogOpen = ref(false)
|
|
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
|
const providerDrawerOpen = ref(false)
|
|
const providerDrawerMounted = ref(false)
|
|
const selectedProviderId = ref<string | null>(null)
|
|
const selectedProviderSnapshot = ref<ProviderWithEndpointsSummary | null>(null)
|
|
const selectedProvider = computed<ProviderWithEndpointsSummary | null>(() => {
|
|
if (!selectedProviderId.value) return null
|
|
return providers.value.find(provider => provider.id === selectedProviderId.value)
|
|
?? (selectedProviderSnapshot.value?.id === selectedProviderId.value ? selectedProviderSnapshot.value : null)
|
|
})
|
|
const providerDeleteProgress = ref<ProviderDeleteProgressState | null>(null)
|
|
let deletePollAbort: AbortController | null = null
|
|
|
|
const DELETE_POLL_INTERVAL_MS = 2000
|
|
const DELETE_POLL_MAX_MS = 30 * 60 * 1000
|
|
const DELETE_POLL_MAX_FAILURES = 3
|
|
const PROVIDER_SUMMARY_CACHE_TTL_MS = 10 * 1000
|
|
|
|
async function pollProviderDeleteTask(providerId: string, taskId: string) {
|
|
deletePollAbort?.abort()
|
|
const abort = new AbortController()
|
|
deletePollAbort = abort
|
|
|
|
const deadline = Date.now() + DELETE_POLL_MAX_MS
|
|
let consecutiveFailures = 0
|
|
|
|
while (Date.now() < deadline) {
|
|
if (abort.signal.aborted) return null
|
|
try {
|
|
const task = await getProviderDeleteTask(providerId, taskId)
|
|
consecutiveFailures = 0
|
|
if (providerDeleteProgress.value?.taskId === taskId) {
|
|
providerDeleteProgress.value = {
|
|
...providerDeleteProgress.value,
|
|
status: task.status,
|
|
stage: task.stage,
|
|
totalKeys: task.total_keys,
|
|
deletedKeys: task.deleted_keys,
|
|
totalEndpoints: task.total_endpoints,
|
|
deletedEndpoints: task.deleted_endpoints,
|
|
message: task.message,
|
|
}
|
|
}
|
|
if (task.status === 'completed' || task.status === 'failed') {
|
|
return task
|
|
}
|
|
} catch {
|
|
consecutiveFailures += 1
|
|
if (consecutiveFailures >= DELETE_POLL_MAX_FAILURES) {
|
|
throw new Error('provider delete task polling failed')
|
|
}
|
|
}
|
|
await new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, DELETE_POLL_INTERVAL_MS)
|
|
abort.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(undefined) }, { once: true })
|
|
})
|
|
}
|
|
|
|
throw new Error('provider delete task timeout')
|
|
}
|
|
|
|
const providerDeleteStageLabel = computed(() => {
|
|
switch (providerDeleteProgress.value?.stage) {
|
|
case 'preparing':
|
|
return legacyT('准备删除')
|
|
case 'disabling':
|
|
return legacyT('停用提供商')
|
|
case 'cleaning_restrictions':
|
|
return legacyT('清理访问限制')
|
|
case 'cleaning_provider_refs':
|
|
return legacyT('清理历史引用')
|
|
case 'deleting_keys':
|
|
return legacyT('删除号池账号')
|
|
case 'deleting_endpoints':
|
|
return legacyT('删除端点')
|
|
case 'completed':
|
|
return legacyT('删除完成')
|
|
case 'failed':
|
|
return legacyT('删除失败')
|
|
default:
|
|
return legacyT('等待执行')
|
|
}
|
|
})
|
|
|
|
const providerDeleteTotalUnits = computed(() => {
|
|
const progress = providerDeleteProgress.value
|
|
if (!progress) return 0
|
|
return progress.totalKeys + progress.totalEndpoints
|
|
})
|
|
|
|
const providerDeleteCompletedUnits = computed(() => {
|
|
const progress = providerDeleteProgress.value
|
|
if (!progress) return 0
|
|
return Math.min(progress.deletedKeys + progress.deletedEndpoints, providerDeleteTotalUnits.value)
|
|
})
|
|
|
|
const providerDeleteOverallPercent = computed(() => {
|
|
const progress = providerDeleteProgress.value
|
|
if (!progress) return 0
|
|
if (progress.status === 'completed') return 100
|
|
if (providerDeleteTotalUnits.value <= 0) return 0
|
|
return Math.min(
|
|
100,
|
|
Math.round((providerDeleteCompletedUnits.value / providerDeleteTotalUnits.value) * 100),
|
|
)
|
|
})
|
|
|
|
const providerDeleteKeysPercent = computed(() => {
|
|
const progress = providerDeleteProgress.value
|
|
if (!progress?.totalKeys) return 0
|
|
return Math.min(100, Math.round((progress.deletedKeys / progress.totalKeys) * 100))
|
|
})
|
|
|
|
const providerDeleteEndpointsPercent = computed(() => {
|
|
const progress = providerDeleteProgress.value
|
|
if (!progress?.totalEndpoints) return 0
|
|
return Math.min(100, Math.round((progress.deletedEndpoints / progress.totalEndpoints) * 100))
|
|
})
|
|
|
|
// Composables
|
|
const {
|
|
searchQuery,
|
|
filterApiFormat,
|
|
apiFormatFilters,
|
|
hasActiveFilters,
|
|
currentPage,
|
|
pageSize,
|
|
total,
|
|
queryParams,
|
|
resetFilters,
|
|
} = useProviderFilters(
|
|
() => [],
|
|
)
|
|
|
|
const {
|
|
loadArchitectureSchemas,
|
|
loadBalances,
|
|
getProviderBalance,
|
|
getProviderBalanceBreakdown,
|
|
getProviderBalanceError,
|
|
isBalanceLoading,
|
|
getProviderCheckin,
|
|
getProviderCookieExpired,
|
|
formatBalanceDisplay,
|
|
formatResetCountdown,
|
|
getProviderBalanceExtra,
|
|
getQuotaUsedColorClass,
|
|
startTick,
|
|
stopTick,
|
|
} = useProviderBalance()
|
|
|
|
// 扩展操作配置对话框
|
|
const opsConfigDialogOpen = ref(false)
|
|
const opsConfigProviderId = ref('')
|
|
const opsConfigProviderWebsite = ref('')
|
|
|
|
// 内联编辑备注
|
|
const editingDescriptionId = ref<string | null>(null)
|
|
|
|
const selectedPolicy = computed(() => schedulingContext.value.activePolicy)
|
|
const priorityEditContext = computed(() => JSON.stringify([
|
|
schedulingContext.value.groupId,
|
|
selectedPolicy.value?.scope,
|
|
selectedPolicy.value?.modelNames,
|
|
]))
|
|
const priorityEditingDisabled = computed(() => schedulingBusy.value || !selectedPolicy.value?.policy
|
|
|| (selectedPolicy.value.scope === 'selected' && selectedPolicy.value.modelNames.length === 0))
|
|
// The active configuration owns this shared order. Directory filters only narrow its display.
|
|
const priorityConfig = computed(() => {
|
|
const config = schedulingContext.value.config
|
|
const selection = selectedPolicy.value
|
|
if (!config || !selection?.policy) return config
|
|
const defaults = getDefaultModelPolicy(config).provider_priority_overrides
|
|
return normalizeRoutingGroupConfig({
|
|
...config,
|
|
model_policies: [{ ...selection.policy, model: '*', provider_priority_overrides: { ...defaults, ...selection.policy.provider_priority_overrides } }],
|
|
})
|
|
})
|
|
function getGroupPriority(provider: ProviderWithEndpointsSummary) {
|
|
return providerGroupPriority(priorityConfig.value, provider)
|
|
}
|
|
function isGroupEnabled(providerId: string) {
|
|
return !schedulingContext.value.config?.disabled_providers?.includes(providerId)
|
|
}
|
|
function setGroupEnabled(providerId: string, enabled: boolean) {
|
|
const config = schedulingContext.value.config
|
|
if (!config || schedulingBusy.value) return
|
|
const disabled = new Set(config.disabled_providers ?? [])
|
|
if (enabled) disabled.delete(providerId)
|
|
else disabled.add(providerId)
|
|
schedulingWorkspace.value?.updateDraftConfig({ ...config, disabled_providers: [...disabled] })
|
|
}
|
|
function setGroupPriority(providerId: string, priority: number) {
|
|
const policy = selectedPolicy.value?.policy
|
|
if (!policy || priorityEditingDisabled.value) return
|
|
schedulingWorkspace.value?.updatePriorityPolicy({
|
|
...policy,
|
|
provider_priority_overrides: { ...policy.provider_priority_overrides, [providerId]: priority },
|
|
})
|
|
}
|
|
const filteredProviders = computed(() => {
|
|
const search = searchQuery.value.trim().toLocaleLowerCase()
|
|
return providers.value.filter(provider => (
|
|
(!search || `${provider.name} ${provider.description ?? ''}`.toLocaleLowerCase().includes(search))
|
|
&& (filterApiFormat.value === 'all' || provider.api_formats?.includes(filterApiFormat.value))
|
|
))
|
|
})
|
|
const providerListRef = ref<HTMLElement | null>(null)
|
|
const {
|
|
orderedProviders,
|
|
draggingProvider,
|
|
dragPreviewStyle,
|
|
announcement,
|
|
startDrag,
|
|
cancelDrag,
|
|
handleSortKeydown,
|
|
handleSortClick,
|
|
sortItemClass,
|
|
} = useProviderPriorityOrder(
|
|
() => sortGroupProviders(priorityConfig.value, filteredProviders.value),
|
|
providerListRef,
|
|
{
|
|
disabled: () => priorityEditingDisabled.value || loading.value,
|
|
move(providerId, targetId) {
|
|
const config = priorityConfig.value
|
|
const policy = selectedPolicy.value?.policy
|
|
if (!config || !policy) return
|
|
const updated = getDefaultModelPolicy(moveGroupProvider(config, providers.value, providerId, targetId))
|
|
schedulingWorkspace.value?.updatePriorityPolicy({ ...policy, provider_priority_overrides: updated.provider_priority_overrides })
|
|
},
|
|
},
|
|
)
|
|
const displayedProviders = computed(() => {
|
|
const start = (currentPage.value - 1) * pageSize.value
|
|
return orderedProviders.value.slice(start, start + pageSize.value)
|
|
})
|
|
watch(() => filteredProviders.value.length, length => {
|
|
total.value = length
|
|
currentPage.value = Math.min(currentPage.value, Math.max(1, Math.ceil(length / pageSize.value)))
|
|
}, { immediate: true })
|
|
watch(() => [schedulingContext.value.busy, selectedPolicy.value?.scope, selectedPolicy.value?.modelNames.join('|')], () => {
|
|
cancelDrag()
|
|
currentPage.value = 1
|
|
})
|
|
|
|
watch([loading, cardView, queryParams], cancelDrag)
|
|
|
|
function startEditDescription(_event: Event, provider: ProviderWithEndpointsSummary) {
|
|
editingDescriptionId.value = provider.id
|
|
}
|
|
|
|
function cancelEditDescription(_event?: Event) {
|
|
editingDescriptionId.value = null
|
|
}
|
|
|
|
async function saveDescription(_event: Event, provider: ProviderWithEndpointsSummary, newValue: string) {
|
|
const trimmed = newValue.trim()
|
|
const oldValue = provider.description || ''
|
|
if (trimmed === oldValue) {
|
|
cancelEditDescription()
|
|
return
|
|
}
|
|
try {
|
|
await updateProvider(provider.id, { description: trimmed || null })
|
|
provider.description = trimmed || undefined
|
|
// 同步更新 providers 数组
|
|
const target = providers.value.find(p => p.id === provider.id)
|
|
if (target) {
|
|
target.description = trimmed || undefined
|
|
}
|
|
cancelEditDescription()
|
|
} catch (err: unknown) {
|
|
showLegacyError(err, '更新备注失败')
|
|
}
|
|
}
|
|
|
|
// 当前已有提供商的最大优先级
|
|
const maxProviderPriority = computed(() => {
|
|
if (providers.value.length === 0) return undefined
|
|
const priorities = providers.value
|
|
.map(p => p.provider_priority)
|
|
.filter(v => typeof v === 'number' && Number.isFinite(v))
|
|
return priorities.length > 0 ? Math.max(...priorities) : undefined
|
|
})
|
|
|
|
// 先取得完整目录,再按当前分组的优先级排序、筛选和分页。
|
|
async function loadProviders(options: { cacheTtlMs?: number } = {}) {
|
|
const requestId = ++providersRequestId
|
|
loading.value = true
|
|
try {
|
|
const response = await getProvidersSummary({ page: 1, page_size: 10_000 }, {
|
|
cacheTtlMs: options.cacheTtlMs ?? 0,
|
|
})
|
|
const items = new Map(response.items.map(item => [item.id, item]))
|
|
let nextPage = 2
|
|
while (items.size < response.total) {
|
|
if (requestId !== providersRequestId) return
|
|
const page = await getProvidersSummary({ page: nextPage++, page_size: 10_000 }, {
|
|
cacheTtlMs: options.cacheTtlMs ?? 0,
|
|
})
|
|
const previousSize = items.size
|
|
page.items.forEach(item => items.set(item.id, item))
|
|
if (items.size === previousSize) break
|
|
}
|
|
if (requestId !== providersRequestId) return
|
|
const existingProviders = new Map(providers.value.map(provider => [provider.id, provider]))
|
|
providers.value = [...items.values()].map((item) => {
|
|
const existing = existingProviders.get(item.id)
|
|
if (!existing) return item
|
|
Object.assign(existing, item)
|
|
return existing
|
|
})
|
|
// 异步加载配置了 ops 的 provider 的余额数据
|
|
loadBalances(providers.value)
|
|
} catch (err: unknown) {
|
|
if (requestId !== providersRequestId) return
|
|
showLegacyError(err, '加载提供商列表失败')
|
|
} finally {
|
|
if (requestId === providersRequestId) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// 使用复用的行点击逻辑
|
|
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
|
|
|
// 处理行点击 - 只在非选中文本时打开抽屉
|
|
function handleRowClick(event: MouseEvent, providerId: string) {
|
|
if (!shouldTriggerRowClick(event)) return
|
|
openProviderDrawer(providerId)
|
|
}
|
|
|
|
// 打开添加提供商对话框
|
|
function openAddProviderDialog() {
|
|
const { groupId, groupName } = schedulingContext.value
|
|
if (!groupId || schedulingBusy.value) {
|
|
showInfo(legacyT('请先创建或选择策略分组'))
|
|
return
|
|
}
|
|
providerCreationGroup.value = { id: groupId, name: groupName }
|
|
providerToEdit.value = null
|
|
providerDialogOpen.value = true
|
|
}
|
|
|
|
function openProviderBatchDialog() {
|
|
providerBatchDialogOpen.value = true
|
|
}
|
|
|
|
async function handleProviderBatchChanged() {
|
|
providerRevision.value += 1
|
|
await loadProviders()
|
|
}
|
|
|
|
// 打开提供商详情抽屉
|
|
function openProviderDrawer(providerId: string) {
|
|
selectedProviderId.value = providerId
|
|
providerDrawerMounted.value = true
|
|
providerDrawerOpen.value = true
|
|
}
|
|
|
|
function mergeUpdatedProvider(updated: ProviderWithEndpointsSummary) {
|
|
if (selectedProviderId.value === updated.id) selectedProviderSnapshot.value = updated
|
|
const index = providers.value.findIndex(p => p.id === updated.id)
|
|
if (index !== -1) {
|
|
Object.assign(providers.value[index], updated)
|
|
loadBalances([providers.value[index]], false)
|
|
}
|
|
}
|
|
|
|
async function refreshProviderSnapshot(
|
|
providerId: string,
|
|
fallbackErrorMessage = '刷新提供商数据失败',
|
|
): Promise<ProviderWithEndpointsSummary | null> {
|
|
try {
|
|
const updated = await getProvider(providerId)
|
|
mergeUpdatedProvider(updated)
|
|
return updated
|
|
} catch (err) {
|
|
showLegacyError(err, fallbackErrorMessage)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// 打开编辑提供商对话框
|
|
async function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
|
|
const latest = await refreshProviderSnapshot(provider.id, '刷新提供商状态失败')
|
|
providerToEdit.value = latest ?? provider
|
|
providerDialogOpen.value = true
|
|
}
|
|
|
|
// 打开扩展操作配置对话框
|
|
function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
|
|
opsConfigProviderId.value = provider.id
|
|
opsConfigProviderWebsite.value = provider.website || ''
|
|
opsConfigDialogOpen.value = true
|
|
}
|
|
|
|
// 扩展操作配置保存回调
|
|
function handleOpsConfigSaved() {
|
|
opsConfigDialogOpen.value = false
|
|
void loadProviders()
|
|
}
|
|
|
|
// 处理提供商编辑完成
|
|
function handleProviderUpdated(updated: ProviderWithEndpointsSummary) {
|
|
mergeUpdatedProvider(updated)
|
|
providerRevision.value += 1
|
|
}
|
|
|
|
// 处理详情抽屉内的刷新:只刷新当前查看的那一条提供商
|
|
async function handleDrawerRefresh() {
|
|
if (!selectedProviderId.value) return
|
|
await refreshProviderSnapshot(selectedProviderId.value)
|
|
providerRevision.value += 1
|
|
}
|
|
|
|
// 处理提供商添加
|
|
function handleProviderAdded() {
|
|
providerRevision.value += 1
|
|
void loadProviders()
|
|
void schedulingWorkspace.value?.refreshGroups()
|
|
}
|
|
|
|
// 删除提供商
|
|
async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
|
const confirmed = await confirmDanger(
|
|
legacyT('删除提供商'),
|
|
legacyT(`确定要删除提供商 "${provider.name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复!`),
|
|
)
|
|
|
|
if (!confirmed) return
|
|
|
|
try {
|
|
const result = await deleteProvider(provider.id)
|
|
providerDeleteProgress.value = {
|
|
providerId: provider.id,
|
|
providerName: provider.name,
|
|
taskId: result.task_id,
|
|
status: result.status,
|
|
stage: 'queued',
|
|
totalKeys: provider.total_keys || 0,
|
|
deletedKeys: 0,
|
|
totalEndpoints: provider.total_endpoints || 0,
|
|
deletedEndpoints: 0,
|
|
message: result.message || '删除任务已提交,后台处理中',
|
|
}
|
|
showInfo(legacyT(result.message || '删除任务已提交,后台处理中'))
|
|
|
|
const task = await pollProviderDeleteTask(provider.id, result.task_id)
|
|
if (!task) return // aborted
|
|
if (task.status === 'failed') {
|
|
throw new Error(task.message || 'provider delete task failed')
|
|
}
|
|
|
|
showSuccess(legacyT('提供商已删除'))
|
|
providerDeleteProgress.value = null
|
|
void loadProviders()
|
|
} catch (err: unknown) {
|
|
providerDeleteProgress.value = null
|
|
showLegacyError(err, '删除提供商失败')
|
|
}
|
|
}
|
|
|
|
// 切换提供商状态
|
|
async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
|
|
try {
|
|
const newStatus = !provider.is_active
|
|
await updateProvider(provider.id, { is_active: newStatus })
|
|
|
|
// 更新抽屉内部的 provider 对象
|
|
provider.is_active = newStatus
|
|
|
|
// 同时更新主页面 providers 数组中的对象,实现无感更新
|
|
const targetProvider = providers.value.find(p => p.id === provider.id)
|
|
if (targetProvider) {
|
|
targetProvider.is_active = newStatus
|
|
}
|
|
providerRevision.value += 1
|
|
|
|
showSuccess(legacyT(newStatus ? '提供商已启用' : '提供商已停用'))
|
|
} catch (err: unknown) {
|
|
showLegacyError(err, '操作失败')
|
|
}
|
|
}
|
|
|
|
// 点击外部自动取消编辑备注
|
|
function handleGlobalClick(event: MouseEvent) {
|
|
if (!editingDescriptionId.value) return
|
|
const target = event.target as HTMLElement
|
|
if (target.closest('[data-desc-editor]')) return
|
|
cancelEditDescription()
|
|
}
|
|
|
|
function ensureResourcesLoaded() {
|
|
if (resourcesLoaded.value) return
|
|
resourcesLoaded.value = true
|
|
void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
|
|
void loadArchitectureSchemas()
|
|
startTick()
|
|
}
|
|
|
|
onMounted(() => {
|
|
ensureResourcesLoaded()
|
|
document.addEventListener('click', handleGlobalClick, true)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
deletePollAbort?.abort()
|
|
document.removeEventListener('click', handleGlobalClick, true)
|
|
stopTick()
|
|
})
|
|
</script>
|