mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
936 lines
32 KiB
Vue
936 lines
32 KiB
Vue
<template>
|
||
<Dialog
|
||
:model-value="modelValue"
|
||
title="号池调度"
|
||
description="管理号池内 Key 的分配模式和排序偏好"
|
||
size="lg"
|
||
@update:model-value="emit('update:modelValue', $event)"
|
||
>
|
||
<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-4 rounded-2xl border border-border/60 bg-card/70 p-4">
|
||
<div class="space-y-1">
|
||
<h3 class="text-sm font-medium">
|
||
分配模式
|
||
</h3>
|
||
<p class="text-xs text-muted-foreground">
|
||
控制 Key 的基础分配方式,选择一种模式。
|
||
</p>
|
||
</div>
|
||
|
||
<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="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
|
||
? 'border-primary bg-primary text-primary-foreground shadow-sm shadow-primary/20'
|
||
: item.applicable
|
||
? '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)"
|
||
>
|
||
{{ item.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
v-if="activeDistributionLabel || activeDistributionDesc"
|
||
class="rounded-xl border border-primary/15 bg-primary/5 px-3 py-2.5"
|
||
>
|
||
<p
|
||
v-if="activeDistributionDesc"
|
||
class="mt-1 text-xs leading-5 text-muted-foreground"
|
||
>
|
||
{{ activeDistributionDesc }}
|
||
</p>
|
||
<div
|
||
v-if="activeDistributionItem?.modeOptions.length"
|
||
data-testid="pool-cache-affinity-secondary-mode"
|
||
class="mt-2 flex w-fit flex-wrap gap-1 rounded-lg bg-muted/50 p-1"
|
||
>
|
||
<button
|
||
v-for="modeOpt in activeDistributionItem.modeOptions"
|
||
:key="modeOpt.value"
|
||
type="button"
|
||
:data-mode="modeOpt.value"
|
||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all"
|
||
:class="[
|
||
activeDistributionItem.mode === modeOpt.value
|
||
? 'bg-primary text-primary-foreground shadow-sm'
|
||
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
|
||
]"
|
||
@click="setPresetModeByPreset(activeDistributionItem.preset, modeOpt.value)"
|
||
>
|
||
{{ modeOpt.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="isCodex"
|
||
class="flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-card/70 p-4"
|
||
>
|
||
<div class="space-y-1">
|
||
<label
|
||
for="pool-reserve-minimum-quota"
|
||
class="text-sm font-medium"
|
||
>
|
||
保留最低额度
|
||
</label>
|
||
<p
|
||
id="pool-reserve-minimum-quota-description"
|
||
class="text-xs leading-5 text-muted-foreground"
|
||
>
|
||
账号剩余额度不高于 1% 时提前标记为额度耗尽并停止调度,待额度恢复后再使用。
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
id="pool-reserve-minimum-quota"
|
||
v-model="reserveMinimumQuota"
|
||
aria-describedby="pool-reserve-minimum-quota-description"
|
||
:disabled="loading"
|
||
class="mt-0.5 shrink-0"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Section 2: 策略调度 (非互斥, 可叠加组合 + 拖拽排序) -->
|
||
<div class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4">
|
||
<div class="space-y-1">
|
||
<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-1.5">
|
||
<div
|
||
v-for="{ index, item } in strategyItems"
|
||
:key="item.preset"
|
||
class="group rounded-xl border px-3 py-2.5 transition-all duration-200"
|
||
:class="[
|
||
!item.applicable
|
||
? 'border-border/40 bg-muted/20 opacity-80'
|
||
: draggedIndex === index
|
||
? 'border-primary/50 bg-primary/5 shadow-md'
|
||
: dragOverIndex === index
|
||
? 'border-primary/30 bg-primary/5'
|
||
: 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)"
|
||
@dragend="handleDragEnd"
|
||
@dragover.prevent="item.applicable && handleDragOver(index)"
|
||
@dragleave="handleDragLeave"
|
||
@drop="item.applicable && handleDrop(index)"
|
||
>
|
||
<div class="flex items-start gap-2.5">
|
||
<!-- Drag handle -->
|
||
<div
|
||
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'"
|
||
>
|
||
<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"
|
||
>
|
||
<!-- 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>
|
||
</div>
|
||
</div>
|
||
</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"
|
||
>
|
||
{{ loading ? '保存中...' : '保存' }}
|
||
</Button>
|
||
</template>
|
||
</Dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { GripVertical } from 'lucide-vue-next'
|
||
import { Dialog, Button, Switch } from '@/components/ui'
|
||
import { useToast } from '@/composables/useToast'
|
||
import { parseApiError } from '@/utils/errorParser'
|
||
import { getProvider, updateProvider } from '@/api/endpoints'
|
||
import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
|
||
import {
|
||
mergePoolAdvancedPatch,
|
||
moveStrategyItem,
|
||
normalizeMutexSelection,
|
||
} from '@/features/pool/utils/poolSchedulingDialog'
|
||
import type { PoolPresetMeta } from '@/api/endpoints/pool'
|
||
import type {
|
||
PoolAdvancedConfig,
|
||
SchedulingPresetItem,
|
||
ProviderWithEndpointsSummary,
|
||
} from '@/api/endpoints/types/provider'
|
||
|
||
interface PresetModeOption {
|
||
value: string
|
||
label: string
|
||
}
|
||
|
||
interface PresetListItem {
|
||
preset: string
|
||
label: string
|
||
desc: string
|
||
enabled: boolean
|
||
mode: string | null
|
||
modeOptions: PresetModeOption[]
|
||
applicable: boolean
|
||
mutexGroup: string | null
|
||
evidenceHint: string
|
||
}
|
||
|
||
const props = defineProps<{
|
||
modelValue: boolean
|
||
providerId: string
|
||
providerType?: string
|
||
currentConfig: PoolAdvancedConfig | null
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: boolean]
|
||
saved: [provider: ProviderWithEndpointsSummary]
|
||
}>()
|
||
|
||
const DISTRIBUTION_GROUP = 'distribution_mode'
|
||
|
||
const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
||
{
|
||
name: 'cache_affinity',
|
||
label: '缓存亲和',
|
||
description: '同一用户持续复用 Key,首次分配可集中或轮转',
|
||
mutex_group: DISTRIBUTION_GROUP,
|
||
evidence_hint: '先复用用户粘性 Key,未命中时按所选二级模式分配',
|
||
providers: [],
|
||
default_enabled: true,
|
||
modes: [
|
||
{ value: 'single_account', label: '单号优先' },
|
||
{ value: 'lru', label: 'LRU 轮号' },
|
||
],
|
||
default_mode: 'single_account',
|
||
},
|
||
{
|
||
name: 'lru',
|
||
label: 'LRU 轮转',
|
||
description: '最久未使用的 Key 优先',
|
||
mutex_group: DISTRIBUTION_GROUP,
|
||
evidence_hint: '依据 LRU 时间戳(最近未使用优先)',
|
||
providers: [],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'single_account',
|
||
label: '单号优先',
|
||
description: '集中使用同一账号(反向 LRU)',
|
||
mutex_group: DISTRIBUTION_GROUP,
|
||
evidence_hint: '先按账号优先级(internal_priority),同级再按反向 LRU 集中',
|
||
providers: [],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'load_balance',
|
||
label: '负载均衡',
|
||
description: '随机分散 Key 使用,均匀分摊负载',
|
||
mutex_group: DISTRIBUTION_GROUP,
|
||
evidence_hint: '每次随机分值,实现完全均匀分散',
|
||
providers: [],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'free_team_first',
|
||
label: 'Free/Team 优先',
|
||
description: '兼容旧配置:优先消耗 Free、Team 或两者',
|
||
evidence_hint: '依据 plan_type,保留旧 free_only/team_only/both 语义',
|
||
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
|
||
modes: [
|
||
{ value: 'free_only', label: 'Free' },
|
||
{ value: 'team_only', label: 'Team' },
|
||
{ value: 'both', label: 'Free + Team' },
|
||
],
|
||
default_mode: 'both',
|
||
},
|
||
{
|
||
name: 'free_first',
|
||
label: 'Free 优先',
|
||
description: '优先消耗 Free 账号(依赖 plan_type)',
|
||
evidence_hint: '依据 plan_type(Free 账号优先调度)',
|
||
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'team_first',
|
||
label: 'Team 优先',
|
||
description: '优先消耗 Team 账号(依赖 plan_type)',
|
||
evidence_hint: '依据 plan_type(Team 账号优先调度)',
|
||
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'plus_first',
|
||
label: 'Plus 优先',
|
||
description: '优先消耗 Plus 账号(依赖 plan_type)',
|
||
evidence_hint: '依据 plan_type(Plus 账号优先调度)',
|
||
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'pro_first',
|
||
label: 'Pro 优先',
|
||
description: '优先消耗 Pro 账号(依赖 plan_type)',
|
||
evidence_hint: '依据 plan_type(Pro 账号优先调度)',
|
||
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'quota_balanced',
|
||
label: '额度平均',
|
||
description: '优先选额度消耗最少的账号',
|
||
evidence_hint: '依据账号配额使用率;无配额时回退到窗口成本使用',
|
||
providers: [],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'recent_refresh',
|
||
label: '额度刷新优先',
|
||
description: '优先选即将刷新额度的账号',
|
||
evidence_hint: '依据账号额度重置倒计时(next_reset / reset_seconds)',
|
||
providers: ['codex', 'grok', 'kiro', 'windsurf', 'xai'],
|
||
default_enabled_providers: ['codex', 'windsurf'],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'priority_first',
|
||
label: '优先级优先',
|
||
description: '按账号优先级顺序调度(数字越小越优先)',
|
||
evidence_hint: '依据 internal_priority(支持拖拽/手工编辑)',
|
||
providers: [],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'health_first',
|
||
label: '健康优先',
|
||
description: '优先选择健康分更高、失败更少的账号',
|
||
evidence_hint: '依据 health_by_format 聚合分(含熔断/失败衰减)',
|
||
providers: [],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'latency_first',
|
||
label: '延迟优先',
|
||
description: '优先选择最近延迟更低的账号',
|
||
evidence_hint: '依据号池延迟窗口均值(latency_window_seconds)',
|
||
providers: [],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
{
|
||
name: 'cost_first',
|
||
label: '成本优先',
|
||
description: '优先选择窗口消耗更低的账号',
|
||
evidence_hint: '依据窗口成本/Token 用量,缺失时回退配额使用率',
|
||
providers: [],
|
||
modes: null,
|
||
default_mode: null,
|
||
},
|
||
]
|
||
|
||
const { success, error: showError } = useToast()
|
||
const loading = ref(false)
|
||
const reserveMinimumQuota = ref(false)
|
||
const isCodex = computed(() => normalizeProviderType(props.providerType) === 'codex')
|
||
let dialogRevision = 0
|
||
const presetDefs = ref<PoolPresetMeta[]>([])
|
||
const presetDefsLoaded = ref(false)
|
||
const loadingPresetDefs = ref(false)
|
||
|
||
const draggedIndex = ref<number | null>(null)
|
||
const dragOverIndex = ref<number | null>(null)
|
||
const presetList = ref<PresetListItem[]>([])
|
||
|
||
function normalizeProviderType(value: string | undefined): string {
|
||
return (value || '').trim().toLowerCase()
|
||
}
|
||
|
||
function normalizePresetName(value: unknown): string {
|
||
return String(value ?? '').trim().toLowerCase()
|
||
}
|
||
|
||
function normalizeMode(value: unknown): string | null {
|
||
const normalized = String(value ?? '').trim().toLowerCase()
|
||
return normalized || null
|
||
}
|
||
|
||
function normalizeMutexGroup(value: unknown): string | null {
|
||
const normalized = String(value ?? '').trim().toLowerCase()
|
||
return normalized || null
|
||
}
|
||
|
||
const FALLBACK_ORDER = FALLBACK_PRESET_DEFS.map(d => d.name)
|
||
|
||
function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
|
||
const ordered: PoolPresetMeta[] = []
|
||
const seen = new Set<string>()
|
||
for (const raw of defs) {
|
||
const name = normalizePresetName(raw.name)
|
||
if (!name || seen.has(name)) continue
|
||
seen.add(name)
|
||
const providers = Array.isArray(raw.providers)
|
||
? raw.providers.map(p => normalizeProviderType(p)).filter(Boolean)
|
||
: []
|
||
const modes = Array.isArray(raw.modes)
|
||
? raw.modes
|
||
.map(mode => ({
|
||
value: normalizePresetName(mode.value),
|
||
label: String(mode.label ?? '').trim() || String(mode.value ?? '').trim(),
|
||
}))
|
||
.filter(mode => Boolean(mode.value))
|
||
: null
|
||
const defaultMode = normalizeMode(raw.default_mode)
|
||
const defaultEnabledProviders = Array.isArray(raw.default_enabled_providers)
|
||
? raw.default_enabled_providers.map(p => normalizeProviderType(p)).filter(Boolean)
|
||
: []
|
||
ordered.push({
|
||
name,
|
||
label: String(raw.label ?? '').trim() || name,
|
||
description: String(raw.description ?? '').trim(),
|
||
providers,
|
||
default_enabled: raw.default_enabled === true,
|
||
default_enabled_providers: defaultEnabledProviders,
|
||
modes: modes && modes.length > 0 ? modes : null,
|
||
default_mode: defaultMode,
|
||
mutex_group: normalizeMutexGroup(raw.mutex_group),
|
||
evidence_hint: String(raw.evidence_hint ?? '').trim() || null,
|
||
})
|
||
}
|
||
// Re-order by FALLBACK_PRESET_DEFS order; unknown presets go to the end.
|
||
ordered.sort((a, b) => {
|
||
const ia = FALLBACK_ORDER.indexOf(a.name)
|
||
const ib = FALLBACK_ORDER.indexOf(b.name)
|
||
return (ia === -1 ? 9999 : ia) - (ib === -1 ? 9999 : ib)
|
||
})
|
||
return ordered
|
||
}
|
||
|
||
function getPresetDefs(): PoolPresetMeta[] {
|
||
if (presetDefs.value.length > 0) {
|
||
return presetDefs.value
|
||
}
|
||
return FALLBACK_PRESET_DEFS
|
||
}
|
||
|
||
async function ensurePresetDefsLoaded(): Promise<void> {
|
||
if (presetDefsLoaded.value || loadingPresetDefs.value) return
|
||
loadingPresetDefs.value = true
|
||
try {
|
||
const remoteDefs = await getPoolSchedulingPresets()
|
||
const normalized = normalizePresetDefs(Array.isArray(remoteDefs) ? remoteDefs : [])
|
||
if (normalized.length > 0) {
|
||
presetDefs.value = normalized
|
||
presetDefsLoaded.value = true
|
||
}
|
||
} catch (err) {
|
||
showError(parseApiError(err))
|
||
} finally {
|
||
loadingPresetDefs.value = false
|
||
}
|
||
}
|
||
|
||
function isApplicablePreset(def: PoolPresetMeta): boolean {
|
||
const providerType = normalizeProviderType(props.providerType)
|
||
const providers = Array.isArray(def.providers) ? def.providers : []
|
||
if (providers.length === 0) return true
|
||
if (!providerType) return true
|
||
return providers.includes(providerType)
|
||
}
|
||
|
||
function getModeOptions(def: PoolPresetMeta): PresetModeOption[] {
|
||
const modes = Array.isArray(def.modes) ? def.modes : []
|
||
return modes
|
||
.map(mode => ({
|
||
value: normalizePresetName(mode.value),
|
||
label: String(mode.label ?? '').trim() || String(mode.value ?? '').trim(),
|
||
}))
|
||
.filter(mode => Boolean(mode.value))
|
||
}
|
||
|
||
function defaultModeForPreset(def: PoolPresetMeta): string | null {
|
||
const options = getModeOptions(def)
|
||
if (options.length === 0) return null
|
||
const normalizedDefault = normalizeMode(def.default_mode)
|
||
if (normalizedDefault && options.some(option => option.value === normalizedDefault)) {
|
||
return normalizedDefault
|
||
}
|
||
return options[0].value
|
||
}
|
||
|
||
function buildPresetListItem(def: PoolPresetMeta, enabled: boolean, mode?: unknown): PresetListItem {
|
||
return {
|
||
preset: def.name,
|
||
label: def.label,
|
||
desc: def.description,
|
||
enabled,
|
||
mode: mode !== undefined ? resolveMode(def, mode) : defaultModeForPreset(def),
|
||
modeOptions: getModeOptions(def),
|
||
applicable: isApplicablePreset(def),
|
||
mutexGroup: normalizeMutexGroup(def.mutex_group),
|
||
evidenceHint: String(def.evidence_hint ?? '').trim(),
|
||
}
|
||
}
|
||
|
||
function buildDefaultPresetList(): PresetListItem[] {
|
||
const providerType = normalizeProviderType(props.providerType)
|
||
return getPresetDefs().map((def) => {
|
||
const providerDefaults = Array.isArray(def.default_enabled_providers)
|
||
? def.default_enabled_providers.map(normalizeProviderType)
|
||
: []
|
||
const enabled = def.default_enabled === true
|
||
|| (Boolean(providerType) && providerDefaults.includes(providerType))
|
||
return buildPresetListItem(def, enabled)
|
||
})
|
||
}
|
||
|
||
function isNewFormatPresetItem(item: unknown): item is SchedulingPresetItem {
|
||
return typeof item === 'object' && item !== null && 'preset' in item
|
||
}
|
||
|
||
function resolveMode(def: PoolPresetMeta, mode: unknown): string | null {
|
||
const options = getModeOptions(def)
|
||
if (options.length === 0) return null
|
||
const normalized = normalizeMode(mode)
|
||
if (normalized && options.some(option => option.value === normalized)) {
|
||
return normalized
|
||
}
|
||
return defaultModeForPreset(def)
|
||
}
|
||
|
||
function insertMissingByPreferredOrder(
|
||
ordered: PresetListItem[],
|
||
seen: Set<string>,
|
||
defs: PoolPresetMeta[],
|
||
defsByName: Map<string, PoolPresetMeta>,
|
||
) {
|
||
// Insert missing presets at their preferred position from FALLBACK_ORDER
|
||
// rather than appending to the end.
|
||
for (const name of FALLBACK_ORDER) {
|
||
if (seen.has(name)) continue
|
||
const def = defsByName.get(name)
|
||
if (!def) continue
|
||
seen.add(name)
|
||
const item = buildPresetListItem(def, false)
|
||
// Find the best insertion point: right after the last item whose
|
||
// FALLBACK_ORDER index is smaller than ours.
|
||
const myIdx = FALLBACK_ORDER.indexOf(name)
|
||
let insertAt = ordered.length
|
||
for (let i = ordered.length - 1; i >= 0; i--) {
|
||
const peerIdx = FALLBACK_ORDER.indexOf(ordered[i].preset)
|
||
if (peerIdx !== -1 && peerIdx < myIdx) {
|
||
insertAt = i + 1
|
||
break
|
||
}
|
||
if (i === 0) insertAt = 0
|
||
}
|
||
ordered.splice(insertAt, 0, item)
|
||
}
|
||
// Any remaining defs not in FALLBACK_ORDER go to the end.
|
||
for (const def of defs) {
|
||
if (seen.has(def.name)) continue
|
||
seen.add(def.name)
|
||
ordered.push(buildPresetListItem(def, false))
|
||
}
|
||
}
|
||
|
||
function reorderDistributionGroup(items: PresetListItem[]): PresetListItem[] {
|
||
// Distribution mode items are rendered as a fixed button group,
|
||
// so their order should always match FALLBACK_ORDER regardless of saved config.
|
||
const distIndexes: number[] = []
|
||
const distItems: PresetListItem[] = []
|
||
items.forEach((item, i) => {
|
||
if (item.mutexGroup === DISTRIBUTION_GROUP) {
|
||
distIndexes.push(i)
|
||
distItems.push(item)
|
||
}
|
||
})
|
||
if (distItems.length <= 1) return items
|
||
|
||
distItems.sort((a, b) => {
|
||
const ia = FALLBACK_ORDER.indexOf(a.preset)
|
||
const ib = FALLBACK_ORDER.indexOf(b.preset)
|
||
return (ia === -1 ? 9999 : ia) - (ib === -1 ? 9999 : ib)
|
||
})
|
||
|
||
const result = [...items]
|
||
distIndexes.forEach((origIdx, i) => {
|
||
result[origIdx] = distItems[i]
|
||
})
|
||
return result
|
||
}
|
||
|
||
function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
|
||
const defs = getPresetDefs()
|
||
const defsByName = new Map(defs.map(def => [def.name, def]))
|
||
const defaults = buildDefaultPresetList()
|
||
if (!cfg) return defaults
|
||
|
||
const rawPresets = cfg.scheduling_presets
|
||
if (!Array.isArray(rawPresets) || rawPresets.length === 0) {
|
||
if (cfg.scheduling_mode === 'lru' || cfg.lru_enabled === true) {
|
||
return defaults.map(item => ({
|
||
...item,
|
||
enabled: item.preset === 'lru',
|
||
}))
|
||
}
|
||
return defaults
|
||
}
|
||
|
||
const first = rawPresets[0]
|
||
if (isNewFormatPresetItem(first)) {
|
||
const configItems = rawPresets as SchedulingPresetItem[]
|
||
const ordered: PresetListItem[] = []
|
||
const seen = new Set<string>()
|
||
|
||
for (const ci of configItems) {
|
||
const presetName = normalizePresetName(ci.preset)
|
||
const def = defsByName.get(presetName)
|
||
if (!def || seen.has(presetName)) continue
|
||
seen.add(presetName)
|
||
ordered.push(buildPresetListItem(def, ci.enabled !== false, ci.mode))
|
||
}
|
||
|
||
insertMissingByPreferredOrder(ordered, seen, defs, defsByName)
|
||
return reorderDistributionGroup(normalizeMutexSelection(ordered))
|
||
}
|
||
|
||
const legacyPresets = rawPresets as string[]
|
||
const lruEnabled = cfg.lru_enabled !== false
|
||
const ordered: PresetListItem[] = []
|
||
const seen = new Set<string>()
|
||
|
||
const lruDef = defsByName.get('lru')
|
||
if (lruDef) {
|
||
ordered.push(buildPresetListItem(lruDef, lruEnabled))
|
||
seen.add('lru')
|
||
}
|
||
|
||
for (const name of legacyPresets) {
|
||
const presetName = normalizePresetName(name)
|
||
const def = defsByName.get(presetName)
|
||
if (!def || seen.has(presetName)) continue
|
||
seen.add(presetName)
|
||
ordered.push(buildPresetListItem(def, true, undefined))
|
||
}
|
||
|
||
insertMissingByPreferredOrder(ordered, seen, defs, defsByName)
|
||
return reorderDistributionGroup(normalizeMutexSelection(ordered))
|
||
}
|
||
|
||
function togglePreset(index: number, enabled: boolean) {
|
||
const item = presetList.value[index]
|
||
if (!item) return
|
||
item.enabled = enabled
|
||
}
|
||
|
||
function selectDistribution(_anchorIndex: number, presetName: string) {
|
||
presetList.value.forEach(item => {
|
||
if (item.mutexGroup === DISTRIBUTION_GROUP) {
|
||
item.enabled = item.preset === presetName && item.applicable
|
||
}
|
||
})
|
||
}
|
||
|
||
function setPresetModeByPreset(preset: string, mode: string) {
|
||
const targetIndex = presetList.value.findIndex(item => item.preset === preset)
|
||
if (targetIndex < 0) return
|
||
presetList.value[targetIndex].mode = mode
|
||
}
|
||
|
||
const distributionItems = computed(() => {
|
||
const items: { index: number; item: PresetListItem }[] = []
|
||
presetList.value.forEach((item, index) => {
|
||
if (item.mutexGroup === DISTRIBUTION_GROUP) {
|
||
items.push({ index, item })
|
||
}
|
||
})
|
||
return items
|
||
})
|
||
|
||
const activeDistributionPreset = computed(() => {
|
||
const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
|
||
return found?.item.preset ?? null
|
||
})
|
||
|
||
const activeDistributionItem = computed(() => {
|
||
const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
|
||
return found?.item ?? null
|
||
})
|
||
|
||
const activeDistributionDesc = computed(() => {
|
||
return activeDistributionItem.value?.desc ?? null
|
||
})
|
||
|
||
const activeDistributionLabel = computed(() => {
|
||
return activeDistributionItem.value?.label ?? null
|
||
})
|
||
|
||
const strategyItems = computed(() => {
|
||
const items: { index: number; item: PresetListItem }[] = []
|
||
presetList.value.forEach((item, index) => {
|
||
if (!item.mutexGroup) {
|
||
items.push({ index, item })
|
||
}
|
||
})
|
||
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) {
|
||
event.dataTransfer.effectAllowed = 'move'
|
||
event.dataTransfer.setData('text/html', '')
|
||
}
|
||
}
|
||
|
||
function handleDragEnd() {
|
||
draggedIndex.value = null
|
||
dragOverIndex.value = null
|
||
}
|
||
|
||
function handleDragOver(index: number) {
|
||
dragOverIndex.value = index
|
||
}
|
||
|
||
function handleDragLeave() {
|
||
dragOverIndex.value = null
|
||
}
|
||
|
||
function handleDrop(dropIndex: number) {
|
||
if (draggedIndex.value === null || draggedIndex.value === dropIndex) {
|
||
draggedIndex.value = null
|
||
dragOverIndex.value = null
|
||
return
|
||
}
|
||
const items = [...presetList.value]
|
||
const [draggedItem] = items.splice(draggedIndex.value, 1)
|
||
items.splice(dropIndex, 0, draggedItem)
|
||
presetList.value = items
|
||
draggedIndex.value = null
|
||
dragOverIndex.value = null
|
||
}
|
||
|
||
watch([() => props.modelValue, () => props.providerId], async ([open]) => {
|
||
const revision = ++dialogRevision
|
||
loading.value = false
|
||
if (!open) return
|
||
reserveMinimumQuota.value = props.currentConfig?.reserve_minimum_quota === true
|
||
await ensurePresetDefsLoaded()
|
||
if (!props.modelValue || dialogRevision !== revision) return
|
||
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
|
||
})
|
||
|
||
async function handleSave() {
|
||
const providerId = props.providerId
|
||
const revision = dialogRevision
|
||
loading.value = true
|
||
try {
|
||
await ensurePresetDefsLoaded()
|
||
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
|
||
if (!presetDefsLoaded.value) {
|
||
showError('调度策略元数据加载失败,请重试')
|
||
return
|
||
}
|
||
presetList.value = normalizeMutexSelection(presetList.value)
|
||
const schedulingPresets: SchedulingPresetItem[] = presetList.value.map(item => {
|
||
const result: SchedulingPresetItem = {
|
||
preset: item.preset,
|
||
enabled: item.enabled && item.applicable,
|
||
}
|
||
if (item.modeOptions.length > 0 && item.mode) {
|
||
result.mode = item.mode
|
||
}
|
||
return result
|
||
})
|
||
|
||
const latestProvider = await getProvider(providerId)
|
||
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
|
||
const latestAdvanced = latestProvider.pool_advanced
|
||
const mergedAdvanced = mergePoolAdvancedPatch(latestAdvanced, {
|
||
scheduling_presets: schedulingPresets,
|
||
...(isCodex.value ? { reserve_minimum_quota: reserveMinimumQuota.value } : {}),
|
||
})
|
||
const payload: Parameters<typeof updateProvider>[1] = {
|
||
pool_advanced: mergedAdvanced as PoolAdvancedConfig,
|
||
}
|
||
const updatedProvider = await updateProvider(providerId, payload)
|
||
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
|
||
|
||
success('号池调度已保存')
|
||
emit('saved', updatedProvider)
|
||
emit('update:modelValue', false)
|
||
} catch (err) {
|
||
showError(parseApiError(err))
|
||
} finally {
|
||
if (dialogRevision === revision) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
</script>
|