feat: 全栈功能增强 - 扩展 provider/pool 管理、完善调度与数据层、重构前端 Pool 页面

后端:
- 扩展 pool_admin payloads 和 provider query models,增强 endpoint key 管理
- 完善 scheduler-core 候选排序与请求候选逻辑
- 增强 usage-runtime 写入、provider-transport 网络层与 OAuth 刷新
- 改进 AI pipeline 响应转换与流式处理
- 扩展 global_models/provider_catalog 数据层查询能力
- 增强 video-tasks-core 多 provider 支持
- 新增大量集成测试覆盖 pool/keys/provider_query/frontdoor

前端:
- 重构 PoolManagement 页面,拆分状态管理/对话框逻辑到独立模块
- 新增 poolAdvancedDialog/poolSchedulingDialog/poolManagementState/poolMobilePresentation 工具函数及测试
- 改进 Dialog 组件与 provider tabs 显示

部署:
- 更新 Rust CI workflow 和 Dockerfile 构建配置

Closes #275
Co-authored-by: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-04-09 13:51:50 +08:00
parent 4fc95adfb9
commit b0b40c16ff
97 changed files with 5816 additions and 1881 deletions

View File

@@ -43,7 +43,7 @@
<slot name="header">
<div
v-if="title"
class="border-b border-border px-6 py-4"
class="border-b border-border px-4 py-4 sm:px-6"
>
<div class="flex items-center gap-3">
<div
@@ -73,14 +73,14 @@
</slot>
<!-- 内容区域可选添加 padding -->
<div :class="noPadding ? '' : 'px-6 py-3'">
<div :class="noPadding ? '' : 'px-4 py-3 sm:px-6'">
<slot />
</div>
<!-- Footer 区域如果有 footer 插槽自动添加样式 -->
<div
v-if="slots.footer"
class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3"
class="border-t border-border bg-muted/10 px-4 py-4 sm:px-6 flex flex-row-reverse gap-3"
>
<slot name="footer" />
</div>

View File

@@ -0,0 +1,44 @@
import { useRoute, useRouter, type LocationQuery, type LocationQueryValue } from 'vue-router'
type QueryValue = LocationQueryValue | LocationQueryValue[]
function normalizeQueryValue(value: QueryValue): string | undefined {
if (Array.isArray(value)) {
return value.length > 0 ? (value[value.length - 1] ?? undefined) : undefined
}
return typeof value === 'string' ? value : undefined
}
function queriesEqual(left: LocationQuery, right: LocationQuery): boolean {
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
for (const key of keys) {
if (normalizeQueryValue(left[key]) !== normalizeQueryValue(right[key])) {
return false
}
}
return true
}
export function useRouteQuery() {
const route = useRoute()
const router = useRouter()
function getQueryValue(key: string): string | undefined {
return normalizeQueryValue(route.query[key])
}
function patchQuery(patch: Record<string, string | undefined | null>) {
const next: LocationQuery = { ...route.query }
for (const [key, value] of Object.entries(patch)) {
if (value == null || value.trim() === '') {
delete next[key]
} else {
next[key] = value
}
}
if (queriesEqual(route.query, next)) return
void router.replace({ query: next }).catch(() => {})
}
return { route, router, getQueryValue, patchQuery }
}

View File

@@ -3,224 +3,235 @@
:model-value="modelValue"
title="账号批量操作"
:description="dialogDescription"
size="xl"
size="3xl"
persistent
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-4">
<div class="flex items-center gap-2">
<MultiSelect
:model-value="activeQuickSelectors"
:options="QUICK_SELECT_OPTIONS"
placeholder="快捷多选"
trigger-class="h-8 w-40"
dropdown-min-width="10rem"
:disabled="loading || executing"
@update:model-value="onQuickSelectChange"
/>
<Input
:model-value="searchText"
placeholder="搜索账号名 / 套餐 / 额度 / 代理状态"
class="h-8 flex-1"
@update:model-value="(v) => searchText = String(v || '')"
/>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:disabled="loading || executing"
@click="loadKeysPage()"
>
<RefreshCw
class="h-3.5 w-3.5"
:class="loading ? 'animate-spin' : ''"
/>
</Button>
</div>
<div class="space-y-3 rounded-lg border bg-muted/20 px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-medium text-foreground">快捷多选</span>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="loading || executing || !hasActiveFilters"
@click="clearFilters"
>
重置筛选
</Button>
</div>
<div
v-if="activeQuickSelectors.length > 0"
class="flex flex-wrap gap-1"
>
<Badge
v-for="sel in activeQuickSelectors"
:key="sel"
variant="secondary"
class="text-[10px] px-1.5 py-0 h-5 cursor-pointer hover:bg-destructive/10 hover:text-destructive"
@click="removeQuickSelector(sel)"
>
{{ QUICK_SELECT_OPTIONS.find(s => s.value === sel)?.label }}
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-0.5"
><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</Badge>
</div>
<div class="flex flex-wrap gap-2">
<Button
v-for="option in QUICK_SELECT_OPTIONS"
:key="option.value"
variant="outline"
size="sm"
class="h-8 px-2.5 text-[11px]"
:class="activeQuickSelectorSet.has(option.value) ? 'border-primary/70 bg-primary/10 text-primary' : ''"
:disabled="loading || executing"
@click="toggleQuickSelector(option.value)"
>
{{ option.label }}
</Button>
</div>
<div class="flex items-center justify-between text-xs">
<div class="text-muted-foreground">
{{ filteredTotal }} 个匹配账号当前页 {{ pageKeys.length }} 已选 {{ selectedCount }}
</div>
<div class="flex items-center gap-2">
<Checkbox
:checked="isAllFilteredSelected"
:indeterminate="isPartiallyFilteredSelected"
:disabled="filteredTotal === 0 || loading || executing"
@update:checked="toggleSelectFiltered"
/>
<span class="text-muted-foreground">全选筛选结果</span>
</div>
</div>
<div class="space-y-3 rounded-md border bg-background/80 px-3 py-3">
<div class="flex items-center gap-2">
<Input
:model-value="searchText"
placeholder="搜索账号名 / 套餐 / 额度 / 代理状态"
class="h-8 flex-1"
@update:model-value="(v) => searchText = String(v || '')"
/>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:disabled="loading || executing"
@click="loadKeysPage()"
>
<RefreshCw
class="h-3.5 w-3.5"
:class="loading ? 'animate-spin' : ''"
/>
</Button>
</div>
<div class="max-h-[380px] overflow-y-auto rounded-lg border">
<div
v-if="loading"
class="py-10 text-center text-sm text-muted-foreground"
>
正在加载账号列表...
</div>
<div
v-else-if="pageKeys.length === 0"
class="py-10 text-center text-sm text-muted-foreground"
>
无匹配账号
</div>
<label
v-for="key in pageKeys"
:key="key.key_id"
class="flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-muted/30"
>
<Checkbox
:checked="selectAllFiltered || selectedIdSet.has(key.key_id)"
:disabled="executing || selectAllFiltered"
@update:checked="(checked) => toggleOne(key.key_id, checked === true)"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="text-xs font-medium truncate">{{ key.key_name || '未命名' }}</span>
<Badge
variant="outline"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ normalizeAuthTypeLabel(key.auth_type) }}</Badge>
<Badge
v-if="getStatusBadgeLabel(key)"
variant="destructive"
class="text-[10px] px-1 py-0 h-4 shrink-0"
:title="getStatusBadgeTitle(key)"
>{{ getStatusBadgeLabel(key) }}</Badge>
<Badge
v-if="key.oauth_plan_type"
variant="outline"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ key.oauth_plan_type }}</Badge>
<Badge
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[10px] px-1 py-0 h-4 shrink-0"
:title="getOAuthOrgBadge(key)?.title"
>{{ getOAuthOrgBadge(key)?.label }}</Badge>
<div class="flex flex-col gap-2 text-xs lg:flex-row lg:items-center lg:justify-between">
<div class="text-muted-foreground">
共 {{ filteredTotal }} 个匹配账号,当前页 {{ pageKeys.length }} 个,已选 {{ selectedCount }} 个
</div>
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
<span v-if="key.proxy?.node_id">独立代理</span>
<span
v-if="key.last_used_at"
class="ml-auto shrink-0"
>{{ formatRelativeTime(key.last_used_at) }}</span>
<div class="flex flex-wrap items-center gap-1">
<div class="mr-1 flex items-center gap-2">
<Checkbox
:checked="isAllFilteredSelected"
:indeterminate="isPartiallyFilteredSelected"
:disabled="filteredTotal === 0 || loading || executing"
@update:checked="toggleSelectFiltered"
/>
<span class="text-muted-foreground">全选筛选结果</span>
</div>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="pageKeys.length === 0 || loading || executing || selectAllFiltered"
@click="toggleSelectCurrentPage"
>
{{ isCurrentPageFullySelected ? '取消本页全选' : '本页全选' }}
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="!canClearSelection || loading || executing"
@click="clearSelection"
>
清空选择
</Button>
</div>
</div>
</label>
</div>
<div
v-if="totalPages > 1"
class="flex items-center justify-between text-xs text-muted-foreground"
>
<span> {{ currentPage }} / {{ totalPages }} </span>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage <= 1"
@click="goToPage(1)"
>
<ChevronsLeft class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
>
<ChevronRight class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage >= totalPages"
@click="goToPage(totalPages)"
>
<ChevronsRight class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<Select v-model="selectedAction">
<SelectTrigger class="h-8 text-xs flex-1">
<SelectValue placeholder="选择动作" />
</SelectTrigger>
<SelectContent>
<SelectItem
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_19rem]">
<div class="min-w-0 space-y-3">
<div class="max-h-[420px] overflow-y-auto rounded-lg border">
<div
v-if="loading"
class="py-10 text-center text-sm text-muted-foreground"
>
正在加载账号列表...
</div>
<div
v-else-if="pageKeys.length === 0"
class="py-10 text-center text-sm text-muted-foreground"
>
无匹配账号
</div>
<label
v-for="key in pageKeys"
:key="key.key_id"
class="flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-muted/30"
>
<Checkbox
:checked="selectAllFiltered || selectedIdSet.has(key.key_id)"
:disabled="executing || selectAllFiltered"
@update:checked="(checked) => toggleOne(key.key_id, checked === true)"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="text-xs font-medium truncate">{{ key.key_name || '未命名' }}</span>
<Badge
variant="outline"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ normalizeAuthTypeLabel(key.auth_type) }}</Badge>
<Badge
v-if="getStatusBadgeLabel(key)"
variant="destructive"
class="text-[10px] px-1 py-0 h-4 shrink-0"
:title="getStatusBadgeTitle(key)"
>{{ getStatusBadgeLabel(key) }}</Badge>
<Badge
v-if="key.oauth_plan_type"
variant="outline"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ key.oauth_plan_type }}</Badge>
<Badge
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[10px] px-1 py-0 h-4 shrink-0"
:title="getOAuthOrgBadge(key)?.title"
>{{ getOAuthOrgBadge(key)?.label }}</Badge>
</div>
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
<span v-if="key.proxy?.node_id">独立代理</span>
<span
v-if="key.last_used_at"
class="ml-auto shrink-0"
>{{ formatRelativeTime(key.last_used_at) }}</span>
</div>
</div>
</label>
</div>
<div
v-if="totalPages > 1"
class="flex items-center justify-between text-xs text-muted-foreground"
>
<span>第 {{ currentPage }} / {{ totalPages }} 页</span>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage <= 1"
@click="goToPage(1)"
>
<ChevronsLeft class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
>
<ChevronRight class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage >= totalPages"
@click="goToPage(totalPages)"
>
<ChevronsRight class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<div class="space-y-3 lg:sticky lg:top-1 lg:self-start">
<div class="space-y-2 rounded-lg border bg-background px-3 py-3">
<div class="text-xs font-medium text-foreground">
执行动作
</div>
<div class="text-[11px] text-muted-foreground">
代理节点(仅“配置代理”动作生效)
</div>
<ProxyNodeSelect
:model-value="proxyNodeIdForAction"
trigger-class="h-8"
@update:model-value="(v: string) => proxyNodeIdForAction = v"
/>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
<Button
v-for="item in ACTION_OPTIONS"
:key="item.value"
:value="item.value"
class="h-8 w-full px-3 text-xs"
:variant="getActionButtonVariant(item)"
:disabled="!canExecuteSpecifiedAction(item.value)"
@click="confirmAndExecuteAction(item.value)"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:disabled="executing || selectedCount === 0 || loading"
@click="executeAction"
>
<Play
class="h-3.5 w-3.5"
:class="executing ? 'animate-pulse' : ''"
/>
</Button>
</Button>
</div>
</div>
</div>
<ProxyNodeSelect
v-if="selectedAction === 'set_proxy'"
:model-value="proxyNodeIdForAction"
trigger-class="h-8"
@update:model-value="(v: string) => proxyNodeIdForAction = v"
/>
</div>
<div
@@ -260,10 +271,9 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Dialog, Button, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Checkbox, Badge } from '@/components/ui'
import { MultiSelect } from '@/components/common'
import { Dialog, Button, Input, Checkbox, Badge } from '@/components/ui'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { RefreshCw, Play, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
import { RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
@@ -308,6 +318,13 @@ type BatchActionValue =
| 'enable'
| 'disable'
type BatchActionOption = {
value: BatchActionValue
label: string
hint: string
destructive?: boolean
}
const props = defineProps<{
modelValue: boolean
providerId: string
@@ -323,26 +340,26 @@ const emit = defineEmits<{
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
{ value: 'banned', label: '账号异常' },
{ value: 'oauth_invalid', label: 'Token 异常' },
{ value: 'no_5h_limit', label: '无5H限额' },
{ value: 'no_weekly_limit', label: '无周限额' },
{ value: 'plan_free', label: '全部 Free' },
{ value: 'plan_team', label: '全部 Team' },
{ value: 'oauth_invalid', label: 'Token 异常' },
{ value: 'proxy_unset', label: '未配置代理' },
{ value: 'proxy_set', label: '已配置独立代理' },
{ value: 'disabled', label: '已禁用' },
{ value: 'enabled', label: '已启用' },
]
const ACTION_OPTIONS: Array<{ value: BatchActionValue; label: string }> = [
{ value: 'export', label: '导出凭据' },
{ value: 'delete', label: '删除账号' },
{ value: 'refresh_oauth', label: '刷新 OAuth' },
{ value: 'refresh_quota', label: '刷新额度' },
{ value: 'clear_proxy', label: '清除代理' },
{ value: 'set_proxy', label: '配置代理' },
{ value: 'enable', label: '启用' },
{ value: 'disable', label: '禁用' },
const ACTION_OPTIONS: BatchActionOption[] = [
{ value: 'refresh_quota', label: '刷新额度', hint: '调用额度刷新接口,适合核对最新配额状态。' },
{ value: 'refresh_oauth', label: '刷新 OAuth', hint: '仅对 OAuth 账号有效,非 OAuth 账号会自动跳过。' },
{ value: 'set_proxy', label: '配置代理', hint: '为选中账号绑定独立代理节点。' },
{ value: 'clear_proxy', label: '清除代理', hint: '移除账号独立代理,回退到提供商默认代理。' },
{ value: 'enable', label: '启用', hint: '批量启用账号,恢复可调度状态。' },
{ value: 'disable', label: '禁用', hint: '批量禁用账号,保留数据但停止调度。' },
{ value: 'export', label: '导出凭据', hint: '仅导出 OAuth 凭据,其他类型账号将被跳过。' },
{ value: 'delete', label: '删除账号', hint: '永久删除账号数据,执行后不可恢复。', destructive: true },
]
const { success, warning, error: showError } = useToast()
@@ -357,7 +374,7 @@ const selectedKeyIds = ref<string[]>([])
const knownKeysById = ref<Record<string, PoolKeyDetail>>({})
const selectAllFiltered = ref(false)
const searchText = ref('')
const selectedAction = ref<BatchActionValue>('delete')
const selectedAction = ref<BatchActionValue>('refresh_quota')
const proxyNodeIdForAction = ref('')
const lastResultMessage = ref('')
const progressTotal = ref(0)
@@ -383,6 +400,21 @@ const selectedCount = computed(() => (selectAllFiltered.value ? filteredTotal.va
const totalPages = computed(() => Math.max(1, Math.ceil(filteredTotal.value / PAGE_SIZE)))
const isAllFilteredSelected = computed(() => selectAllFiltered.value && filteredTotal.value > 0)
const isPartiallyFilteredSelected = computed(() => !selectAllFiltered.value && selectedKeyIds.value.length > 0)
const hasActiveFilters = computed(() => searchText.value.trim().length > 0 || activeQuickSelectors.value.length > 0)
const selectedOnCurrentPageCount = computed(() => {
if (selectAllFiltered.value) return pageKeys.value.length
let count = 0
for (const key of pageKeys.value) {
if (selectedIdSet.value.has(key.key_id)) count += 1
}
return count
})
const isCurrentPageFullySelected = computed(() => {
if (selectAllFiltered.value || pageKeys.value.length === 0) return false
return selectedOnCurrentPageCount.value === pageKeys.value.length
})
const canClearSelection = computed(() => selectAllFiltered.value || selectedKeyIds.value.length > 0)
const activeQuickSelectorSet = computed(() => new Set(activeQuickSelectors.value))
function normalizeText(value: unknown): string {
return String(value || '').trim().toLowerCase()
@@ -592,17 +624,76 @@ function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
}
}
function onQuickSelectChange(values: string[]): void {
activeQuickSelectors.value = values as QuickSelectorValue[]
function toggleSelectCurrentPage(): void {
if (selectAllFiltered.value || pageKeys.value.length === 0) return
const set = new Set(selectedKeyIds.value)
const pageIds = pageKeys.value.map((key) => key.key_id)
const shouldUnselect = pageIds.every((id) => set.has(id))
for (const id of pageIds) {
if (shouldUnselect) set.delete(id)
else set.add(id)
}
selectedKeyIds.value = [...set]
}
function clearSelection(): void {
resetSelection()
}
function clearFilters(): void {
if (!hasActiveFilters.value) return
clearSearchDebounce()
suppressFilterWatch = true
searchText.value = ''
activeQuickSelectors.value = []
suppressFilterWatch = false
requestFilteredReload()
}
function removeQuickSelector(selector: QuickSelectorValue): void {
function toggleQuickSelector(selector: QuickSelectorValue): void {
const idx = activeQuickSelectors.value.indexOf(selector)
if (idx >= 0) {
activeQuickSelectors.value.splice(idx, 1)
requestFilteredReload()
} else {
activeQuickSelectors.value.push(selector)
}
requestFilteredReload()
}
function canExecuteSpecifiedAction(action: BatchActionValue): boolean {
if (executing.value || loading.value || selectedCount.value === 0) return false
if (action === 'set_proxy') return Boolean(proxyNodeIdForAction.value)
return true
}
function getActionButtonVariant(option: BatchActionOption): 'default' | 'destructive' | 'outline' {
if (option.destructive) return 'destructive'
return 'outline'
}
async function confirmAndExecuteAction(action: BatchActionValue): Promise<void> {
selectedAction.value = action
if (selectedCount.value === 0) {
warning('请先选择账号')
return
}
if (action === 'set_proxy' && !proxyNodeIdForAction.value) {
warning('请先选择代理节点')
return
}
if (!canExecuteSpecifiedAction(action)) return
const actionOption = ACTION_OPTIONS.find((item) => item.value === action)
const actionLabel = actionOption?.label || '执行动作'
const scopeLabel = selectAllFiltered.value ? '筛选结果' : '已选账号'
const confirmed = await confirm({
title: actionLabel,
message: `将对${scopeLabel}${selectedCount.value} 个)执行:${actionLabel},是否继续?`,
confirmText: actionOption?.destructive ? '确认删除' : '确认执行',
...(actionOption?.destructive ? { variant: 'destructive' as const } : {}),
})
if (!confirmed) return
await executeAction(action)
}
const DELETE_POLL_INTERVAL_MS = 2000
@@ -654,24 +745,17 @@ async function resolveSelectedItems(): Promise<PoolKeySelectionItem[]> {
})
}
async function executeAction(): Promise<void> {
async function executeAction(actionOverride?: BatchActionValue): Promise<void> {
if (executing.value) return
if (actionOverride) {
selectedAction.value = actionOverride
}
if (selectedCount.value === 0) {
warning('请先选择账号')
return
}
const requestedCount = selectedCount.value
if (selectedAction.value === 'delete') {
const confirmed = await confirm({
title: '删除账号',
message: `将删除 ${requestedCount} 个账号,操作不可恢复,是否继续?`,
confirmText: '确认删除',
variant: 'destructive',
})
if (!confirmed) return
}
if (selectedAction.value === 'set_proxy' && !proxyNodeIdForAction.value) {
warning('请先选择代理节点')
return
@@ -918,6 +1002,8 @@ watch(
searchText.value = ''
lastResultMessage.value = ''
activeQuickSelectors.value = []
selectedAction.value = 'refresh_quota'
proxyNodeIdForAction.value = ''
resetSelection(true)
filteredTotal.value = 0
pageKeys.value = []

View File

@@ -3,71 +3,96 @@
:model-value="modelValue"
title="高级设置"
description="冷却、健康、成本控制与其他高级参数"
size="lg"
size="3xl"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-4">
<!-- Cooldown & Health -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
冷却与健康
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">健康策略</span>
<p class="text-xs text-muted-foreground">
按上游错误自动冷却并跳过账号
</p>
<div class="max-h-[calc(100dvh-13rem)] space-y-5 overflow-y-auto overscroll-contain pr-1 sm:max-h-[min(72vh,42rem)] sm:space-y-6 sm:pr-2">
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
冷却与健康
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
核心策略
</span>
</div>
<Switch
:model-value="form.health_policy_enabled"
@update:model-value="(v: boolean) => form.health_policy_enabled = v"
/>
<p class="text-xs leading-5 text-muted-foreground">
控制自动冷却主动探测异常清理和全局调度优先级
</p>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">主动探测</span>
<p class="text-xs text-muted-foreground">
按固定间隔主动刷新 Key 的账号状态与额度
</p>
</div>
<Switch
:model-value="form.probing_enabled"
@update:model-value="(v: boolean) => form.probing_enabled = v"
/>
</div>
<div
v-if="form.probing_enabled"
class="grid grid-cols-2 gap-4"
>
<div class="space-y-1.5">
<Label>
探测间隔
<span class="text-xs text-muted-foreground">(分钟)</span>
</Label>
<Input
:model-value="form.probing_interval_minutes ?? ''"
type="number"
min="1"
max="1440"
placeholder="10"
@update:model-value="(v) => form.probing_interval_minutes = parseNum(v)"
<div class="grid gap-3 lg:grid-cols-3">
<div
v-for="item in healthToggleCards"
:key="item.key"
class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between lg:items-center"
>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium">{{ item.label }}</span>
<TooltipProvider
:delay-duration="100"
>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
:title="item.description"
:aria-label="`${item.label} 说明`"
class="hidden lg:inline-flex items-center justify-center rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
>
<CircleHelp class="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
:side-offset="8"
class="max-w-xs px-3 py-2 text-xs leading-5"
>
{{ item.description }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p class="text-xs leading-5 text-muted-foreground lg:hidden">
{{ item.description }}
</p>
</div>
<Switch
:model-value="getHealthToggleValue(item.key)"
class="shrink-0"
@update:model-value="(v: boolean) => updateHealthToggleValue(item.key, v)"
/>
</div>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">异常自动清除</span>
<p class="text-xs text-muted-foreground">
仅在检测到不可恢复的账号异常时自动从号池中移除,不处理纯 Token 失效
</p>
<div
v-if="form.probing_enabled"
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-1.5">
<Label>
探测间隔
<span class="text-xs text-muted-foreground">(分钟)</span>
</Label>
<Input
:model-value="form.probing_interval_minutes ?? ''"
type="number"
min="1"
max="1440"
placeholder="10"
@update:model-value="(v) => form.probing_interval_minutes = parseNum(v)"
/>
</div>
</div>
<Switch
:model-value="form.auto_remove_banned_keys"
@update:model-value="(v: boolean) => form.auto_remove_banned_keys = v"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div
class="grid gap-3 sm:grid-cols-2"
:class="cooldownFieldLayout.desktopColumnsClass"
>
<div class="space-y-1.5">
<Label>
429 冷却
@@ -96,8 +121,6 @@
@update:model-value="(v) => form.overload_cooldown_seconds = parseNum(v)"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
粘性会话 TTL
@@ -115,7 +138,6 @@
<div class="space-y-1.5">
<Label>
全局优先级
<span class="text-xs text-muted-foreground">(global_key)</span>
</Label>
<Input
:model-value="form.global_priority ?? ''"
@@ -127,93 +149,200 @@
/>
</div>
</div>
</div>
</section>
<!-- Batch Operations -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
批量操作
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
并发数
</Label>
<Input
:model-value="form.batch_concurrency ?? ''"
type="number"
min="1"
max="32"
placeholder="8"
@update:model-value="(v) => form.batch_concurrency = parseNum(v)"
/>
<p class="text-[11px] text-muted-foreground">
批量刷新 OAuth / 额度等操作的并行请求数
<div :class="secondarySectionLayout.wrapperClass">
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
成本控制
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
额度保护
</span>
</div>
<p class="text-xs leading-5 text-muted-foreground">
控制窗口期、Key 限额与软阈值,防止个别账号短时间内过度消耗。
</p>
</div>
</div>
<div
class="grid gap-3 sm:grid-cols-2"
:class="costFieldLayout.desktopColumnsClass"
>
<div class="space-y-1.5">
<Label>
成本窗口
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.cost_window_seconds ?? ''"
type="number"
min="3600"
max="86400"
placeholder="18000 (5 小时)"
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
Key 窗口限额
<span class="text-xs text-muted-foreground">(tokens)</span>
</Label>
<Input
:model-value="form.cost_limit_per_key_tokens ?? ''"
type="number"
min="0"
placeholder="留空 = 不限"
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
软阈值
<span class="text-xs text-muted-foreground">(%)</span>
</Label>
<Input
:model-value="form.cost_soft_threshold_percent ?? ''"
type="number"
min="0"
max="100"
placeholder="80"
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
/>
</div>
</div>
</section>
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
批量操作
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
任务效率
</span>
</div>
<p class="text-xs leading-5 text-muted-foreground">
控制刷新 OAuth、主动探测和批量额度处理时的并行请求数。
</p>
</div>
<div class="rounded-xl bg-muted/30 p-4">
<div class="space-y-1.5">
<Label>
并发数
</Label>
<Input
:model-value="form.batch_concurrency ?? ''"
type="number"
min="1"
max="32"
placeholder="8"
@update:model-value="(v) => form.batch_concurrency = parseNum(v)"
/>
<p class="text-[11px] leading-5 text-muted-foreground">
为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。
</p>
</div>
</div>
</section>
</div>
<!-- Claude Code -->
<div
<section
v-if="isClaudeCode"
class="space-y-3"
class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5"
>
<h3 class="text-sm font-medium border-b pb-2">
Claude Code
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">Session ID 伪装</span>
<p class="text-xs text-muted-foreground">
固定 metadata.user_id 中 session 片段
</p>
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
Claude Code
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
请求约束
</span>
</div>
<Switch
:model-value="claudeForm.session_id_masking_enabled"
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
/>
<p class="text-xs leading-5 text-muted-foreground">
管理 CLI 请求限制、会话控制和 metadata / cache 相关的兼容行为。
</p>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">仅限 CLI 客户端</span>
<p class="text-xs text-muted-foreground">
仅允许 Claude Code CLI 格式请求
</p>
<div class="grid gap-3 lg:grid-cols-2">
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<span class="text-sm font-medium">Session ID 伪装</span>
<p class="text-xs leading-5 text-muted-foreground">
固定 metadata.user_id 中 session 片段。
</p>
</div>
<Switch
:model-value="claudeForm.session_id_masking_enabled"
class="shrink-0"
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
/>
</div>
<Switch
:model-value="claudeForm.cli_only_enabled"
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
/>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">Cache TTL 统一</span>
<p class="text-xs text-muted-foreground">
强制所有 cache_control 使用相同 TTL 类型
</p>
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<span class="text-sm font-medium">仅限 CLI 客户端</span>
<p class="text-xs leading-5 text-muted-foreground">
仅允许 Claude Code CLI 格式请求。
</p>
</div>
<Switch
:model-value="claudeForm.cli_only_enabled"
class="shrink-0"
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
/>
</div>
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<span class="text-sm font-medium">Cache TTL 统一</span>
<p class="text-xs leading-5 text-muted-foreground">
强制所有 cache_control 使用同一种 TTL 类型。
</p>
</div>
<Switch
:model-value="claudeForm.cache_ttl_override_enabled"
class="shrink-0"
@update:model-value="(v: boolean) => claudeForm.cache_ttl_override_enabled = v"
/>
</div>
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<span class="text-sm font-medium">会话数量控制</span>
<p class="text-xs leading-5 text-muted-foreground">
限制单 Key 同时活跃会话数,降低长期占用风险。
</p>
</div>
<Switch
:model-value="claudeForm.session_control_enabled"
class="shrink-0"
@update:model-value="(v: boolean) => claudeForm.session_control_enabled = v"
/>
</div>
<Switch
:model-value="claudeForm.cache_ttl_override_enabled"
@update:model-value="(v: boolean) => claudeForm.cache_ttl_override_enabled = v"
/>
</div>
<div
v-if="claudeForm.cache_ttl_override_enabled"
class="pl-3"
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
>
<div class="space-y-1.5">
<Label>TTL 类型</Label>
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md w-fit">
<div class="flex w-fit gap-0.5 rounded-md bg-muted/40 p-0.5">
<button
v-for="opt in ['ephemeral']"
:key="opt"
type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
class="rounded px-2.5 py-1 text-xs font-medium transition-all"
:class="[
claudeForm.cache_ttl_override_target === opt
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
: 'text-muted-foreground hover:bg-background/50 hover:text-foreground'
]"
@click="claudeForm.cache_ttl_override_target = opt"
>
@@ -222,112 +351,55 @@
</div>
</div>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">会话数量控制</span>
<p class="text-xs text-muted-foreground">
限制单 Key 同时活跃会话数
</p>
</div>
<Switch
:model-value="claudeForm.session_control_enabled"
@update:model-value="(v: boolean) => claudeForm.session_control_enabled = v"
/>
</div>
<div
v-if="claudeForm.session_control_enabled"
class="grid grid-cols-2 gap-4"
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
>
<div class="space-y-1.5">
<Label>
最大会话数
</Label>
<Input
:model-value="claudeForm.max_sessions ?? ''"
type="number"
min="1"
max="100"
placeholder="留空 = 不限"
@update:model-value="(v) => claudeForm.max_sessions = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
空闲超时
<span class="text-xs text-muted-foreground">(分钟)</span>
</Label>
<Input
:model-value="claudeForm.session_idle_timeout_minutes ?? ''"
type="number"
min="1"
max="1440"
placeholder="5"
@update:model-value="(v) => claudeForm.session_idle_timeout_minutes = parseNum(v) ?? 5"
/>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-1.5">
<Label>
最大会话数
</Label>
<Input
:model-value="claudeForm.max_sessions ?? ''"
type="number"
min="1"
max="100"
placeholder="留空 = 不限"
@update:model-value="(v) => claudeForm.max_sessions = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
空闲超时
<span class="text-xs text-muted-foreground">(分钟)</span>
</Label>
<Input
:model-value="claudeForm.session_idle_timeout_minutes ?? ''"
type="number"
min="1"
max="1440"
placeholder="5"
@update:model-value="(v) => claudeForm.session_idle_timeout_minutes = parseNum(v) ?? 5"
/>
</div>
</div>
</div>
</div>
<!-- Cost Control -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
成本控制
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
成本窗口
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.cost_window_seconds ?? ''"
type="number"
min="3600"
max="86400"
placeholder="18000 (5 小时)"
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
Key 窗口限额
<span class="text-xs text-muted-foreground">(tokens)</span>
</Label>
<Input
:model-value="form.cost_limit_per_key_tokens ?? ''"
type="number"
min="0"
placeholder="留空 = 不限"
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
软阈值
<span class="text-xs text-muted-foreground">(%)</span>
</Label>
<Input
:model-value="form.cost_soft_threshold_percent ?? ''"
type="number"
min="0"
max="100"
placeholder="80"
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
/>
</div>
</div>
</div>
</section>
</div>
<template #footer>
<Button
variant="outline"
class="min-w-[96px] flex-1 sm:flex-none"
:disabled="loading"
@click="emit('update:modelValue', false)"
>
取消
</Button>
<Button
class="min-w-[96px] flex-1 sm:flex-none"
:disabled="loading"
@click="handleSave"
>
@@ -339,10 +411,18 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
import { CircleHelp } from 'lucide-vue-next'
import { Dialog, Button, Input, Label, Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
import {
buildPoolCooldownFieldLayout,
buildPoolCostFieldLayout,
buildPoolHealthToggleCards,
buildPoolSecondarySectionLayout,
type PoolHealthToggleKey,
} from '@/features/pool/utils/poolAdvancedDialog'
import type {
PoolAdvancedConfig,
ClaudeCodeAdvancedConfig,
@@ -369,6 +449,11 @@ const isClaudeCode = computed(() => {
return (props.providerType || '').trim().toLowerCase() === 'claude_code'
})
const healthToggleCards = buildPoolHealthToggleCards()
const cooldownFieldLayout = buildPoolCooldownFieldLayout()
const costFieldLayout = buildPoolCostFieldLayout()
const secondarySectionLayout = buildPoolSecondarySectionLayout()
const form = ref({
global_priority: null as number | null | undefined,
sticky_session_ttl_seconds: null as number | null | undefined,
@@ -410,6 +495,30 @@ function parseNum(v: string | number): number | undefined {
return Number.isNaN(n) ? undefined : n
}
function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
switch (key) {
case 'health_policy_enabled':
return form.value.health_policy_enabled
case 'probing_enabled':
return form.value.probing_enabled
case 'auto_remove_banned_keys':
return form.value.auto_remove_banned_keys
}
}
function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void {
switch (key) {
case 'health_policy_enabled':
form.value.health_policy_enabled = value
return
case 'probing_enabled':
form.value.probing_enabled = value
return
case 'auto_remove_banned_keys':
form.value.auto_remove_banned_keys = value
}
}
watch(() => props.modelValue, (open) => {
if (!open) return

View File

@@ -6,11 +6,11 @@
size="lg"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-6">
<div class="max-h-[calc(100dvh-13rem)] space-y-5 overflow-y-auto overscroll-contain pr-1 sm:max-h-[min(72vh,42rem)] sm:space-y-6 sm:pr-2">
<!-- Section 1: 分配模式 (distribution_mode 互斥组, 四选一) -->
<div class="space-y-3">
<div class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4">
<div class="space-y-1">
<h3 class="text-sm font-medium border-b pb-2">
<h3 class="text-sm font-medium">
分配模式
</h3>
<p class="text-xs text-muted-foreground">
@@ -18,19 +18,19 @@
</p>
</div>
<div class="flex gap-0.5 p-1 bg-muted/40 rounded-lg">
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<button
v-for="{ index, item } in distributionItems"
:key="item.preset"
type="button"
class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
class="min-h-11 rounded-xl border px-3 py-2.5 text-sm font-medium leading-tight transition-all duration-200"
:disabled="!item.applicable"
:class="[
activeDistributionPreset === item.preset
? 'bg-primary text-primary-foreground shadow-sm'
? 'border-primary bg-primary text-primary-foreground shadow-sm shadow-primary/20'
: item.applicable
? 'text-muted-foreground hover:text-foreground hover:bg-background/60'
: 'text-muted-foreground/40 cursor-not-allowed'
? 'border-border/60 bg-background text-foreground hover:border-border hover:bg-muted/40'
: 'border-border/30 bg-muted/20 text-muted-foreground/50 cursor-not-allowed'
]"
@click="item.applicable && selectDistribution(index, item.preset)"
>
@@ -38,38 +38,53 @@
</button>
</div>
<p
v-if="activeDistributionDesc"
class="text-xs text-muted-foreground px-1"
<div
v-if="activeDistributionLabel || activeDistributionDesc"
class="rounded-xl border border-primary/15 bg-primary/5 px-3 py-2.5"
>
{{ activeDistributionDesc }}
</p>
<p
v-if="activeDistributionDesc"
class="mt-1 text-xs leading-5 text-muted-foreground"
>
{{ activeDistributionDesc }}
</p>
</div>
</div>
<!-- Section 2: 策略调度 (非互斥, 可叠加组合 + 拖拽排序) -->
<div class="space-y-3">
<div class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4">
<div class="space-y-1">
<h3 class="text-sm font-medium border-b pb-2">
策略调度
</h3>
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-medium">
策略调度
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
已启用 {{ enabledStrategyCount }}
</span>
</div>
<p class="text-xs text-muted-foreground">
在分配模式基础上叠加排序因素可组合启用拖拽调整优先级
在分配模式基础上叠加排序因素可组合启用
</p>
<p class="text-xs text-muted-foreground">
桌面端支持拖拽排序移动端可点按上下调整优先级
</p>
</div>
<div class="space-y-0.5">
<div class="space-y-1.5">
<div
v-for="{ index, item } in strategyItems"
:key="item.preset"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
class="group rounded-xl border px-3 py-2.5 transition-all duration-200"
:class="[
!item.applicable
? 'border-border/30 bg-muted/20 opacity-50'
? 'border-border/40 bg-muted/20 opacity-80'
: draggedIndex === index
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
? 'border-primary/50 bg-primary/5 shadow-md'
: dragOverIndex === index
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
: item.enabled
? 'border-primary/20 bg-primary/5 hover:border-primary/30'
: 'border-border/60 bg-background hover:border-border hover:bg-muted/30'
]"
:draggable="item.applicable"
@dragstart="item.applicable && handleDragStart(index, $event)"
@@ -78,57 +93,98 @@
@dragleave="handleDragLeave"
@drop="item.applicable && handleDrop(index)"
>
<!-- Drag handle -->
<div
class="p-1 rounded transition-colors shrink-0"
:class="item.applicable
? 'cursor-grab active:cursor-grabbing text-muted-foreground/40 group-hover:text-muted-foreground'
: 'text-muted-foreground/15 cursor-default'"
>
<GripVertical class="w-4 h-4" />
</div>
<Switch
:model-value="item.enabled"
:disabled="!item.applicable"
@update:model-value="(v: boolean) => togglePreset(index, v)"
/>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium"
:class="!item.applicable ? 'text-muted-foreground' : ''"
>{{ item.label }}</span>
<span
v-if="!item.applicable"
class="text-[10px] text-muted-foreground/60"
>(不适用)</span>
</div>
<p class="text-xs text-muted-foreground mt-0.5">
{{ item.desc }}
</p>
<!-- Mode sub-config -->
<div class="flex items-start gap-2.5">
<!-- Drag handle -->
<div
v-if="item.modeOptions.length > 0 && item.enabled && item.applicable"
class="flex gap-0.5 mt-2 p-0.5 bg-muted/40 rounded-md w-fit"
class="hidden rounded-lg p-1 transition-colors sm:flex sm:shrink-0"
:class="item.applicable
? 'cursor-grab active:cursor-grabbing text-muted-foreground/40 group-hover:text-muted-foreground'
: 'text-muted-foreground/20 cursor-default'"
>
<button
v-for="modeOpt in item.modeOptions"
:key="modeOpt.value"
type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
:class="[
item.mode === modeOpt.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="setPresetModeByPreset(item.preset, modeOpt.value)"
<GripVertical class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2.5">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
class="text-sm font-medium"
:class="!item.applicable ? 'text-muted-foreground' : 'text-foreground'"
>{{ item.label }}</span>
<span
v-if="getStrategyPriority(index)"
class="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
>
#{{ getStrategyPriority(index) }}
</span>
<span
v-if="!item.applicable"
class="rounded-full border border-border/60 bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
>
当前不可用
</span>
</div>
<p class="mt-0.5 text-xs leading-5 text-muted-foreground">
{{ item.desc }}
</p>
</div>
<div
v-if="item.applicable"
class="flex shrink-0 items-center gap-1.5 sm:hidden"
>
<button
type="button"
class="inline-flex h-7 items-center justify-center rounded-lg border border-border/60 px-2.5 text-[11px] font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
:disabled="!canMoveStrategy(index, -1)"
@click="moveStrategy(index, -1)"
>
上移
</button>
<button
type="button"
class="inline-flex h-7 items-center justify-center rounded-lg border border-border/60 px-2.5 text-[11px] font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
:disabled="!canMoveStrategy(index, 1)"
@click="moveStrategy(index, 1)"
>
下移
</button>
</div>
<Switch
:model-value="item.enabled && item.applicable"
:disabled="!item.applicable"
class="mt-0.5 shrink-0"
@update:model-value="(v: boolean) => togglePreset(index, v)"
/>
</div>
<div
v-if="(item.modeOptions.length > 0 && item.enabled && item.applicable) || item.applicable"
class="mt-2 flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between"
>
{{ modeOpt.label }}
</button>
<!-- Mode sub-config -->
<div
v-if="item.modeOptions.length > 0 && item.enabled && item.applicable"
class="flex flex-wrap gap-1 rounded-lg bg-muted/40 p-1"
>
<button
v-for="modeOpt in item.modeOptions"
:key="modeOpt.value"
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all"
:class="[
item.mode === modeOpt.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
]"
@click="setPresetModeByPreset(item.preset, modeOpt.value)"
>
{{ modeOpt.label }}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -139,12 +195,14 @@
<template #footer>
<Button
variant="outline"
class="min-w-[96px] flex-1 sm:flex-none"
:disabled="loading"
@click="emit('update:modelValue', false)"
>
取消
</Button>
<Button
class="min-w-[96px] flex-1 sm:flex-none"
:disabled="loading"
@click="handleSave"
>
@@ -162,6 +220,7 @@ import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
import type { PoolPresetMeta } from '@/api/endpoints/pool'
import type {
PoolAdvancedConfig,
@@ -665,6 +724,11 @@ const activeDistributionDesc = computed(() => {
return found?.item.desc ?? null
})
const activeDistributionLabel = computed(() => {
const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
return found?.item.label ?? null
})
const strategyItems = computed(() => {
const items: { index: number; item: PresetListItem }[] = []
presetList.value.forEach((item, index) => {
@@ -675,6 +739,37 @@ const strategyItems = computed(() => {
return items
})
const enabledStrategyPriorityMap = computed(() => {
const priorities = new Map<number, number>()
let priority = 0
strategyItems.value.forEach(({ index, item }) => {
if (!item.enabled || !item.applicable) return
priority += 1
priorities.set(index, priority)
})
return priorities
})
const enabledStrategyCount = computed(() => enabledStrategyPriorityMap.value.size)
function getStrategyPriority(index: number): number | null {
return enabledStrategyPriorityMap.value.get(index) ?? null
}
function canMoveStrategy(index: number, direction: -1 | 1): boolean {
const strategyIndexes = strategyItems.value.map(({ index: currentIndex }) => currentIndex)
const currentPosition = strategyIndexes.indexOf(index)
if (currentPosition === -1) return false
const targetPosition = currentPosition + direction
return targetPosition >= 0 && targetPosition < strategyIndexes.length
}
function moveStrategy(index: number, direction: -1 | 1) {
presetList.value = moveStrategyItem(presetList.value, index, direction)
}
function handleDragStart(index: number, event: DragEvent) {
draggedIndex.value = index
if (event.dataTransfer) {

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
buildPoolCooldownFieldLayout,
buildPoolHealthToggleCards,
buildPoolCostFieldLayout,
buildPoolSecondarySectionLayout,
} from '@/features/pool/utils/poolAdvancedDialog'
describe('poolAdvancedDialog', () => {
it('returns health toggle cards in the desktop display order', () => {
expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([
'health_policy_enabled',
'probing_enabled',
'auto_remove_banned_keys',
])
})
it('provides tooltip copy for every desktop health toggle card', () => {
expect(buildPoolHealthToggleCards()).toEqual([
{
key: 'health_policy_enabled',
label: '健康策略',
description: '按上游错误自动冷却并跳过异常账号。',
},
{
key: 'probing_enabled',
label: '主动探测',
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
},
{
key: 'auto_remove_banned_keys',
label: '异常自动清除',
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
},
])
})
it('returns the four cooldown-related fields in one desktop row order', () => {
expect(buildPoolCooldownFieldLayout()).toEqual({
fields: [
'rate_limit_cooldown_seconds',
'overload_cooldown_seconds',
'sticky_session_ttl_seconds',
'global_priority',
],
desktopColumnsClass: 'xl:grid-cols-4',
})
})
it('stacks batch and cost sections as full-width rows on desktop', () => {
expect(buildPoolSecondarySectionLayout()).toEqual({
wrapperClass: 'space-y-4',
})
})
it('returns the three cost fields in one desktop row order', () => {
expect(buildPoolCostFieldLayout()).toEqual({
fields: [
'cost_window_seconds',
'cost_limit_per_key_tokens',
'cost_soft_threshold_percent',
],
desktopColumnsClass: 'xl:grid-cols-3',
})
})
})

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
buildPoolManagementQueryPatch,
readPoolManagementViewState,
writePoolManagementViewState,
} from '@/features/pool/utils/poolManagementState'
function createMemoryStorage() {
const store = new Map<string, string>()
return {
getItem(key: string) {
return store.get(key) ?? null
},
setItem(key: string, value: string) {
store.set(key, value)
},
removeItem(key: string) {
store.delete(key)
},
}
}
describe('poolManagementState', () => {
let storage: ReturnType<typeof createMemoryStorage>
beforeEach(() => {
storage = createMemoryStorage()
})
it('restores provider, filters and paging from query first', () => {
writePoolManagementViewState(
{
providerId: 'provider-a',
search: 'stored search',
status: 'cooldown',
page: 5,
pageSize: 20,
},
storage,
)
const state = readPoolManagementViewState(
{
providerId: 'provider-b',
search: 'query search',
status: 'inactive',
page: '3',
pageSize: '100',
},
storage,
)
expect(state).toEqual({
providerId: 'provider-b',
search: 'query search',
status: 'inactive',
page: 3,
pageSize: 100,
})
})
it('falls back to storage when query is missing', () => {
writePoolManagementViewState(
{
providerId: 'provider-c',
search: 'stored only',
status: 'active',
page: 2,
pageSize: 50,
},
storage,
)
const state = readPoolManagementViewState({}, storage)
expect(state).toEqual({
providerId: 'provider-c',
search: 'stored only',
status: 'active',
page: 2,
pageSize: 50,
})
})
it('omits defaults when building query patch', () => {
expect(
buildPoolManagementQueryPatch({
providerId: 'provider-d',
search: ' ',
status: 'all',
page: 1,
pageSize: 50,
}),
).toEqual({
providerId: 'provider-d',
search: undefined,
status: undefined,
page: undefined,
pageSize: undefined,
})
})
})

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
buildPoolMobileTagItems,
splitPoolMobileActions,
} from '@/features/pool/utils/poolMobilePresentation'
describe('poolMobilePresentation', () => {
it('prioritizes critical mobile tags before secondary identity tags', () => {
expect(
buildPoolMobileTagItems({
priorityLabel: 'P40',
authLabel: 'OAuth',
oauthStatusLabel: 'Token 过期',
oauthStatusTone: 'danger',
accountStatusLabel: '账号停用',
accountStatusTone: 'danger',
planLabel: 'Team',
orgLabel: 'Org A',
proxyLabel: '独立代理',
}).map(item => item.label),
).toEqual([
'账号停用',
'Token 过期',
'P40',
'OAuth',
'Team',
'Org A',
'独立代理',
])
})
it('keeps all available mobile actions inline without overflow grouping', () => {
expect(
splitPoolMobileActions({
canRefreshToken: true,
canClearCooldown: true,
canRecoverHealth: true,
canDownloadOrCopy: true,
hasProxy: true,
}),
).toEqual({
primary: [
'copy_or_download',
'refresh_token',
'clear_cooldown',
'recover_health',
'permissions',
'proxy',
'edit',
'toggle',
'delete',
],
overflow: [],
})
})
})

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
interface TestPresetItem {
preset: string
mutexGroup: string | null
enabled: boolean
}
function buildItems(): TestPresetItem[] {
return [
{ preset: 'cache_affinity', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'lru', mutexGroup: 'distribution_mode', enabled: true },
{ preset: 'single_account', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'load_balance', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'recent_refresh', mutexGroup: null, enabled: true },
{ preset: 'quota_balanced', mutexGroup: null, enabled: false },
{ preset: 'priority_first', mutexGroup: null, enabled: true },
]
}
describe('poolSchedulingDialog', () => {
it('moves only strategy items upward without disturbing distribution presets', () => {
const moved = moveStrategyItem(buildItems(), 6, -1)
expect(moved.map(item => item.preset)).toEqual([
'cache_affinity',
'lru',
'single_account',
'load_balance',
'recent_refresh',
'priority_first',
'quota_balanced',
])
})
it('keeps the original order when a strategy item is already at the top boundary', () => {
const original = buildItems()
const moved = moveStrategyItem(original, 4, -1)
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
})
it('moves a strategy item downward within the strategy group', () => {
const moved = moveStrategyItem(buildItems(), 4, 1)
expect(moved.map(item => item.preset)).toEqual([
'cache_affinity',
'lru',
'single_account',
'load_balance',
'quota_balanced',
'recent_refresh',
'priority_first',
])
})
it('keeps the original order when a strategy item is already at the bottom boundary', () => {
const original = buildItems()
const moved = moveStrategyItem(original, 6, 1)
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
})
it('keeps the original order when the target item is not a strategy preset', () => {
const original = buildItems()
const moved = moveStrategyItem(original, 1, 1)
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
})
})

View File

@@ -0,0 +1,73 @@
export type PoolHealthToggleKey =
| 'health_policy_enabled'
| 'probing_enabled'
| 'auto_remove_banned_keys'
export interface PoolHealthToggleCard {
key: PoolHealthToggleKey
label: string
description: string
}
export interface PoolCooldownFieldLayout {
fields: string[]
desktopColumnsClass: string
}
export interface PoolSecondarySectionLayout {
wrapperClass: string
}
export interface PoolCostFieldLayout {
fields: string[]
desktopColumnsClass: string
}
export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
return [
{
key: 'health_policy_enabled',
label: '健康策略',
description: '按上游错误自动冷却并跳过异常账号。',
},
{
key: 'probing_enabled',
label: '主动探测',
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
},
{
key: 'auto_remove_banned_keys',
label: '异常自动清除',
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
},
]
}
export function buildPoolCooldownFieldLayout(): PoolCooldownFieldLayout {
return {
fields: [
'rate_limit_cooldown_seconds',
'overload_cooldown_seconds',
'sticky_session_ttl_seconds',
'global_priority',
],
desktopColumnsClass: 'xl:grid-cols-4',
}
}
export function buildPoolSecondarySectionLayout(): PoolSecondarySectionLayout {
return {
wrapperClass: 'space-y-4',
}
}
export function buildPoolCostFieldLayout(): PoolCostFieldLayout {
return {
fields: [
'cost_window_seconds',
'cost_limit_per_key_tokens',
'cost_soft_threshold_percent',
],
desktopColumnsClass: 'xl:grid-cols-3',
}
}

View File

@@ -0,0 +1,129 @@
export type PoolManagementStatus = 'all' | 'active' | 'cooldown' | 'inactive'
export interface PoolManagementViewState {
providerId: string | null
search: string
status: PoolManagementStatus
page: number
pageSize: number
}
export interface PoolManagementStateSource {
providerId?: string
search?: string
status?: string
page?: string
pageSize?: string
}
export interface StorageLike {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
export const POOL_MANAGEMENT_VIEW_STORAGE_KEY = 'aether:pool-management:view-state'
export const DEFAULT_POOL_MANAGEMENT_VIEW_STATE: PoolManagementViewState = {
providerId: null,
search: '',
status: 'all',
page: 1,
pageSize: 50,
}
function normalizeProviderId(value: unknown): string | null {
const normalized = String(value ?? '').trim()
return normalized || null
}
function normalizeSearch(value: unknown): string {
return String(value ?? '')
}
function normalizeStatus(value: unknown): PoolManagementStatus {
if (value === 'active' || value === 'cooldown' || value === 'inactive') {
return value
}
return 'all'
}
function normalizePositiveInteger(value: unknown, fallback: number): number {
const normalized = Number.parseInt(String(value ?? ''), 10)
if (!Number.isFinite(normalized) || normalized <= 0) {
return fallback
}
return normalized
}
function normalizeViewState(input: Partial<PoolManagementViewState>): PoolManagementViewState {
return {
providerId: normalizeProviderId(input.providerId),
search: normalizeSearch(input.search),
status: normalizeStatus(input.status),
page: normalizePositiveInteger(input.page, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.page),
pageSize: normalizePositiveInteger(input.pageSize, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize),
}
}
function readStoredState(storage?: StorageLike): Partial<PoolManagementViewState> {
if (!storage) return {}
try {
const raw = storage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Partial<PoolManagementViewState> | null
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
export function readPoolManagementViewState(
source: PoolManagementStateSource,
storage?: StorageLike,
): PoolManagementViewState {
const stored = normalizeViewState(readStoredState(storage))
return normalizeViewState({
providerId: source.providerId ?? stored.providerId,
search: source.search ?? stored.search,
status: source.status ?? stored.status,
page: source.page ?? stored.page,
pageSize: source.pageSize ?? stored.pageSize,
})
}
export function writePoolManagementViewState(
state: PoolManagementViewState,
storage?: StorageLike,
): void {
if (!storage) return
try {
storage.setItem(
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
JSON.stringify(normalizeViewState(state)),
)
} catch {
// 忽略存储失败,避免影响主流程。
}
}
export function buildPoolManagementQueryPatch(
state: PoolManagementViewState,
): Record<string, string | undefined> {
const normalized = normalizeViewState(state)
const search = normalized.search.trim()
return {
providerId: normalized.providerId || undefined,
search: search || undefined,
status: normalized.status === 'all' ? undefined : normalized.status,
page: normalized.page <= 1 ? undefined : String(normalized.page),
pageSize:
normalized.pageSize === DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize
? undefined
: String(normalized.pageSize),
}
}

View File

@@ -0,0 +1,108 @@
export type PoolMobileTagTone = 'default' | 'muted' | 'warning' | 'danger' | 'accent'
export interface PoolMobileTagItem {
key: string
label: string
tone: PoolMobileTagTone
}
export interface PoolMobileTagInput {
priorityLabel?: string | null
authLabel?: string | null
oauthStatusLabel?: string | null
oauthStatusTone?: PoolMobileTagTone | null
accountStatusLabel?: string | null
accountStatusTone?: PoolMobileTagTone | null
planLabel?: string | null
orgLabel?: string | null
proxyLabel?: string | null
}
export type PoolMobileActionId =
| 'copy_or_download'
| 'refresh_token'
| 'clear_cooldown'
| 'recover_health'
| 'permissions'
| 'proxy'
| 'edit'
| 'toggle'
| 'delete'
export interface PoolMobileActionInput {
canDownloadOrCopy?: boolean
canRefreshToken?: boolean
canClearCooldown?: boolean
canRecoverHealth?: boolean
hasProxy?: boolean
}
function createTagItem(
key: string,
label: string | null | undefined,
tone: PoolMobileTagTone,
): PoolMobileTagItem | null {
if (!label) return null
return { key, label, tone }
}
export function buildPoolMobileTagItems(input: PoolMobileTagInput): PoolMobileTagItem[] {
return [
createTagItem('account', input.accountStatusLabel, input.accountStatusTone ?? 'warning'),
createTagItem('oauth', input.oauthStatusLabel, input.oauthStatusTone ?? 'warning'),
createTagItem('priority', input.priorityLabel, 'muted'),
createTagItem('auth', input.authLabel, 'default'),
createTagItem('plan', input.planLabel, 'accent'),
createTagItem('org', input.orgLabel, 'accent'),
createTagItem('proxy', input.proxyLabel, 'muted'),
].filter((item): item is PoolMobileTagItem => item !== null)
}
export function splitPoolMobileActions(input: PoolMobileActionInput): {
primary: PoolMobileActionId[]
overflow: PoolMobileActionId[]
} {
if (input.canDownloadOrCopy) {
const primary: PoolMobileActionId[] = ['copy_or_download']
if (input.canRefreshToken) {
primary.push('refresh_token')
}
if (input.canClearCooldown) {
primary.push('clear_cooldown')
}
if (input.canRecoverHealth) {
primary.push('recover_health')
}
primary.push('permissions')
if (input.hasProxy) {
primary.push('proxy')
}
primary.push('edit', 'toggle', 'delete')
return {
primary,
overflow: [],
}
}
const primary: PoolMobileActionId[] = []
if (input.canRefreshToken) {
primary.push('refresh_token')
}
if (input.canClearCooldown) {
primary.push('clear_cooldown')
}
if (input.canRecoverHealth) {
primary.push('recover_health')
}
primary.push('permissions')
if (input.hasProxy) {
primary.push('proxy')
}
primary.push('edit', 'toggle', 'delete')
return {
primary,
overflow: [],
}
}

View File

@@ -0,0 +1,35 @@
export interface SchedulingDialogPresetLike {
mutexGroup: string | null
}
export function moveStrategyItem<T extends SchedulingDialogPresetLike>(
items: readonly T[],
itemIndex: number,
direction: -1 | 1,
): T[] {
const strategyIndexes: number[] = []
items.forEach((item, index) => {
if (!item.mutexGroup) {
strategyIndexes.push(index)
}
})
const currentPosition = strategyIndexes.indexOf(itemIndex)
if (currentPosition === -1) {
return [...items]
}
const targetPosition = currentPosition + direction
if (targetPosition < 0 || targetPosition >= strategyIndexes.length) {
return [...items]
}
const sourceIndex = strategyIndexes[currentPosition]
const targetIndex = strategyIndexes[targetPosition]
const nextItems = [...items]
;[nextItems[sourceIndex], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[sourceIndex]]
return nextItems
}

View File

@@ -409,7 +409,7 @@ const { error: showError, success: showSuccess } = useToast()
const modelTest = useModelTest({ providerId: () => props.provider.id })
// 状态
const loading = ref(false)
const localLoading = ref(false)
const dialogOpen = ref(false)
const deleteConfirmOpen = ref(false)
const editingGroup = ref<AliasGroup | null>(null)
@@ -429,7 +429,7 @@ const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraf
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
const isLoading = computed(() => Boolean(props.loading) || loading.value)
const isLoading = computed(() => Boolean(props.loading) || localLoading.value)
// 使用 props 传入的数据
const models = computed(() => props.models ?? [])

View File

@@ -285,7 +285,7 @@ const { copyToClipboard } = useClipboard()
const modelTest = useModelTest({ providerId: () => props.provider.id })
// 状态
const loading = ref(false)
const localLoading = ref(false)
const localModels = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
const pendingTestModel = ref<Model | null>(null)
@@ -301,7 +301,7 @@ const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.er
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
const models = computed(() => props.models ?? localModels.value)
const isLoading = computed(() => Boolean(props.loading) || loading.value)
const isLoading = computed(() => Boolean(props.loading) || localLoading.value)
// 按名称排序的模型列表
const sortedModels = computed(() => {
return [...models.value].sort((a, b) => {

File diff suppressed because it is too large Load Diff