mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -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>
|
||||
|
||||
44
frontend/src/composables/useRouteQuery.ts
Normal file
44
frontend/src/composables/useRouteQuery.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
73
frontend/src/features/pool/utils/poolAdvancedDialog.ts
Normal file
73
frontend/src/features/pool/utils/poolAdvancedDialog.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
129
frontend/src/features/pool/utils/poolManagementState.ts
Normal file
129
frontend/src/features/pool/utils/poolManagementState.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
108
frontend/src/features/pool/utils/poolMobilePresentation.ts
Normal file
108
frontend/src/features/pool/utils/poolMobilePresentation.ts
Normal 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: [],
|
||||
}
|
||||
}
|
||||
35
frontend/src/features/pool/utils/poolSchedulingDialog.ts
Normal file
35
frontend/src/features/pool/utils/poolSchedulingDialog.ts
Normal 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
|
||||
}
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user