2026-03-04 22:06:19 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="modelValue"
|
|
|
|
|
|
title="号池调度"
|
2026-03-05 23:22:25 +08:00
|
|
|
|
description="管理号池内 Key 的分配模式和排序偏好"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
size="lg"
|
|
|
|
|
|
@update:model-value="emit('update:modelValue', $event)"
|
|
|
|
|
|
>
|
2026-03-05 23:22:25 +08:00
|
|
|
|
<div class="space-y-6">
|
|
|
|
|
|
<!-- Section 1: 分配模式 (distribution_mode 互斥组, 四选一) -->
|
2026-03-04 22:06:19 +08:00
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
|
<h3 class="text-sm font-medium border-b pb-2">
|
2026-03-05 23:22:25 +08:00
|
|
|
|
分配模式
|
2026-03-04 22:06:19 +08:00
|
|
|
|
</h3>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
2026-03-05 23:22:25 +08:00
|
|
|
|
控制 Key 的基础分配方式,选择一种模式。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-0.5 p-1 bg-muted/40 rounded-lg">
|
|
|
|
|
|
<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"
|
|
|
|
|
|
:disabled="!item.applicable"
|
|
|
|
|
|
:class="[
|
|
|
|
|
|
activeDistributionPreset === item.preset
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: item.applicable
|
|
|
|
|
|
? 'text-muted-foreground hover:text-foreground hover:bg-background/60'
|
|
|
|
|
|
: 'text-muted-foreground/40 cursor-not-allowed'
|
|
|
|
|
|
]"
|
|
|
|
|
|
@click="item.applicable && selectDistribution(index, item.preset)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item.label }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<p
|
|
|
|
|
|
v-if="activeDistributionDesc"
|
|
|
|
|
|
class="text-xs text-muted-foreground px-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ activeDistributionDesc }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Section 2: 策略调度 (非互斥, 可叠加组合 + 拖拽排序) -->
|
|
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
|
<h3 class="text-sm font-medium border-b pb-2">
|
|
|
|
|
|
策略调度
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
在分配模式基础上叠加排序因素,可组合启用,拖拽调整优先级。
|
2026-03-04 22:06:19 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-0.5">
|
|
|
|
|
|
<div
|
2026-03-05 23:22:25 +08:00
|
|
|
|
v-for="{ index, item } in strategyItems"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
:key="item.preset"
|
|
|
|
|
|
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
|
|
|
|
|
|
:class="[
|
2026-03-05 23:22:25 +08:00
|
|
|
|
!item.applicable
|
2026-03-04 22:06:19 +08:00
|
|
|
|
? 'border-border/30 bg-muted/20 opacity-50'
|
|
|
|
|
|
: draggedIndex === index
|
|
|
|
|
|
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
|
|
|
|
|
: dragOverIndex === index
|
|
|
|
|
|
? 'border-primary/30 bg-primary/5'
|
|
|
|
|
|
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
|
|
|
|
|
]"
|
2026-03-05 23:22:25 +08:00
|
|
|
|
:draggable="item.applicable"
|
|
|
|
|
|
@dragstart="item.applicable && handleDragStart(index, $event)"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
@dragend="handleDragEnd"
|
2026-03-05 23:22:25 +08:00
|
|
|
|
@dragover.prevent="item.applicable && handleDragOver(index)"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
@dragleave="handleDragLeave"
|
2026-03-05 23:22:25 +08:00
|
|
|
|
@drop="item.applicable && handleDrop(index)"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
>
|
|
|
|
|
|
<!-- Drag handle -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="p-1 rounded transition-colors shrink-0"
|
2026-03-05 23:22:25 +08:00
|
|
|
|
:class="item.applicable
|
2026-03-04 22:06:19 +08:00
|
|
|
|
? '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"
|
2026-03-05 23:22:25 +08:00
|
|
|
|
:class="!item.applicable ? 'text-muted-foreground' : ''"
|
|
|
|
|
|
>{{ item.label }}</span>
|
2026-03-04 22:06:19 +08:00
|
|
|
|
<span
|
2026-03-05 23:22:25 +08:00
|
|
|
|
v-if="!item.applicable"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
class="text-[10px] text-muted-foreground/60"
|
2026-03-05 23:22:25 +08:00
|
|
|
|
>(不适用)</span>
|
2026-03-04 22:06:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground mt-0.5">
|
2026-03-05 23:22:25 +08:00
|
|
|
|
{{ item.desc }}
|
2026-03-04 22:06:19 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Mode sub-config -->
|
|
|
|
|
|
<div
|
2026-03-05 23:22:25 +08:00
|
|
|
|
v-if="item.modeOptions.length > 0 && item.enabled && item.applicable"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
class="flex gap-0.5 mt-2 p-0.5 bg-muted/40 rounded-md w-fit"
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
2026-03-05 23:22:25 +08:00
|
|
|
|
v-for="modeOpt in item.modeOptions"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
:key="modeOpt.value"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
|
|
|
|
|
|
:class="[
|
2026-03-05 23:22:25 +08:00
|
|
|
|
item.mode === modeOpt.value
|
2026-03-04 22:06:19 +08:00
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
|
|
|
|
|
]"
|
2026-03-05 23:22:25 +08:00
|
|
|
|
@click="setPresetModeByPreset(item.preset, modeOpt.value)"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
>
|
|
|
|
|
|
{{ modeOpt.label }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
:disabled="loading"
|
|
|
|
|
|
@click="emit('update:modelValue', false)"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
: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'
|
2026-03-05 15:15:26 +08:00
|
|
|
|
import { Dialog, Button, Switch } from '@/components/ui'
|
2026-03-04 22:06:19 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
|
|
|
|
|
import { updateProvider } from '@/api/endpoints'
|
|
|
|
|
|
import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
|
|
|
|
|
|
import type { PoolPresetMeta } from '@/api/endpoints/pool'
|
2026-03-05 09:53:23 +08:00
|
|
|
|
import type {
|
|
|
|
|
|
PoolAdvancedConfig,
|
|
|
|
|
|
SchedulingPresetItem,
|
|
|
|
|
|
ProviderWithEndpointsSummary,
|
|
|
|
|
|
} from '@/api/endpoints/types/provider'
|
2026-03-04 22:06:19 +08:00
|
|
|
|
|
|
|
|
|
|
interface PresetModeOption {
|
|
|
|
|
|
value: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface PresetListItem {
|
|
|
|
|
|
preset: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
desc: string
|
|
|
|
|
|
enabled: boolean
|
|
|
|
|
|
mode: string | null
|
|
|
|
|
|
modeOptions: PresetModeOption[]
|
|
|
|
|
|
applicable: boolean
|
2026-03-05 09:53:23 +08:00
|
|
|
|
mutexGroup: string | null
|
|
|
|
|
|
evidenceHint: string
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
modelValue: boolean
|
|
|
|
|
|
providerId: string
|
|
|
|
|
|
providerType?: string
|
|
|
|
|
|
currentConfig: PoolAdvancedConfig | null
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'update:modelValue': [value: boolean]
|
2026-03-05 09:53:23 +08:00
|
|
|
|
saved: [provider: ProviderWithEndpointsSummary]
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
const DISTRIBUTION_GROUP = 'distribution_mode'
|
|
|
|
|
|
|
2026-03-04 22:06:19 +08:00
|
|
|
|
const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
2026-03-05 23:22:25 +08:00
|
|
|
|
{
|
|
|
|
|
|
name: 'cache_affinity',
|
|
|
|
|
|
label: '缓存亲和',
|
|
|
|
|
|
description: '优先复用最近使用过的 Key,利用 Prompt Caching',
|
|
|
|
|
|
mutex_group: DISTRIBUTION_GROUP,
|
|
|
|
|
|
evidence_hint: '依据 LRU 时间戳(最近使用优先,与 LRU 轮转相反)',
|
|
|
|
|
|
providers: [],
|
|
|
|
|
|
modes: null,
|
|
|
|
|
|
default_mode: null,
|
|
|
|
|
|
},
|
2026-03-04 22:06:19 +08:00
|
|
|
|
{
|
|
|
|
|
|
name: 'lru',
|
|
|
|
|
|
label: 'LRU 轮转',
|
|
|
|
|
|
description: '最久未使用的 Key 优先',
|
2026-03-05 23:22:25 +08:00
|
|
|
|
mutex_group: DISTRIBUTION_GROUP,
|
2026-03-05 09:53:23 +08:00
|
|
|
|
evidence_hint: '依据 LRU 时间戳(最近未使用优先)',
|
2026-03-04 22:06:19 +08:00
|
|
|
|
providers: [],
|
|
|
|
|
|
modes: null,
|
|
|
|
|
|
default_mode: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-03-05 23:22:25 +08:00
|
|
|
|
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_first',
|
|
|
|
|
|
label: 'Free 优先',
|
|
|
|
|
|
description: '优先消耗 Free 账号(依赖 plan_type)',
|
|
|
|
|
|
evidence_hint: '依据 plan_type(Free 账号优先调度)',
|
2026-03-04 22:06:19 +08:00
|
|
|
|
providers: ['codex', 'kiro'],
|
2026-03-05 23:22:25 +08:00
|
|
|
|
modes: null,
|
|
|
|
|
|
default_mode: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'team_first',
|
|
|
|
|
|
label: 'Team 优先',
|
|
|
|
|
|
description: '优先消耗 Team 账号(依赖 plan_type)',
|
|
|
|
|
|
evidence_hint: '依据 plan_type(Team 账号优先调度)',
|
|
|
|
|
|
providers: ['codex', 'kiro'],
|
|
|
|
|
|
modes: null,
|
|
|
|
|
|
default_mode: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'plus_first',
|
|
|
|
|
|
label: 'Plus 优先',
|
|
|
|
|
|
description: '优先消耗 Plus/Pro 账号(依赖 plan_type)',
|
|
|
|
|
|
evidence_hint: '依据 plan_type(Plus/Pro 账号优先调度)',
|
|
|
|
|
|
providers: ['codex', 'kiro'],
|
|
|
|
|
|
modes: null,
|
|
|
|
|
|
default_mode: null,
|
2026-03-04 22:06:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'quota_balanced',
|
|
|
|
|
|
label: '额度平均',
|
|
|
|
|
|
description: '优先选额度消耗最少的账号',
|
2026-03-05 09:53:23 +08:00
|
|
|
|
evidence_hint: '依据账号配额使用率;无配额时回退到窗口成本使用',
|
2026-03-04 22:06:19 +08:00
|
|
|
|
providers: [],
|
|
|
|
|
|
modes: null,
|
|
|
|
|
|
default_mode: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'recent_refresh',
|
|
|
|
|
|
label: '额度刷新优先',
|
|
|
|
|
|
description: '优先选即将刷新额度的账号',
|
2026-03-05 09:53:23 +08:00
|
|
|
|
evidence_hint: '依据账号额度重置倒计时(next_reset / reset_seconds)',
|
2026-03-04 22:06:19 +08:00
|
|
|
|
providers: ['codex', 'kiro'],
|
|
|
|
|
|
modes: null,
|
|
|
|
|
|
default_mode: null,
|
|
|
|
|
|
},
|
2026-03-05 09:53:23 +08:00
|
|
|
|
{
|
|
|
|
|
|
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 用量,缺失时回退配额使用率',
|
2026-03-04 22:06:19 +08:00
|
|
|
|
providers: [],
|
|
|
|
|
|
modes: null,
|
|
|
|
|
|
default_mode: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-09 14:07:20 +08:00
|
|
|
|
const DEFAULT_ENABLED_PRESETS = new Set(['cache_affinity', 'recent_refresh'])
|
2026-03-04 22:06:19 +08:00
|
|
|
|
|
|
|
|
|
|
const { success, error: showError } = useToast()
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 09:53:23 +08:00
|
|
|
|
function normalizeMutexGroup(value: unknown): string | null {
|
|
|
|
|
|
const normalized = String(value ?? '').trim().toLowerCase()
|
|
|
|
|
|
return normalized || null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
const FALLBACK_ORDER = FALLBACK_PRESET_DEFS.map(d => d.name)
|
|
|
|
|
|
|
2026-03-04 22:06:19 +08:00
|
|
|
|
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)
|
|
|
|
|
|
ordered.push({
|
|
|
|
|
|
name,
|
|
|
|
|
|
label: String(raw.label ?? '').trim() || name,
|
|
|
|
|
|
description: String(raw.description ?? '').trim(),
|
|
|
|
|
|
providers,
|
|
|
|
|
|
modes: modes && modes.length > 0 ? modes : null,
|
|
|
|
|
|
default_mode: defaultMode,
|
2026-03-05 09:53:23 +08:00
|
|
|
|
mutex_group: normalizeMutexGroup(raw.mutex_group),
|
|
|
|
|
|
evidence_hint: String(raw.evidence_hint ?? '').trim() || null,
|
2026-03-04 22:06:19 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-03-05 23:22:25 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
2026-03-04 22:06:19 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(parseApiError(err))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
presetDefsLoaded.value = true
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
function buildPresetListItem(def: PoolPresetMeta, enabled: boolean, mode?: unknown): PresetListItem {
|
|
|
|
|
|
return {
|
2026-03-04 22:06:19 +08:00
|
|
|
|
preset: def.name,
|
|
|
|
|
|
label: def.label,
|
|
|
|
|
|
desc: def.description,
|
2026-03-05 23:22:25 +08:00
|
|
|
|
enabled,
|
|
|
|
|
|
mode: mode !== undefined ? resolveMode(def, mode) : defaultModeForPreset(def),
|
2026-03-04 22:06:19 +08:00
|
|
|
|
modeOptions: getModeOptions(def),
|
|
|
|
|
|
applicable: isApplicablePreset(def),
|
2026-03-05 09:53:23 +08:00
|
|
|
|
mutexGroup: normalizeMutexGroup(def.mutex_group),
|
|
|
|
|
|
evidenceHint: String(def.evidence_hint ?? '').trim(),
|
2026-03-05 23:22:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildDefaultPresetList(): PresetListItem[] {
|
|
|
|
|
|
return getPresetDefs().map(def => buildPresetListItem(def, DEFAULT_ENABLED_PRESETS.has(def.name)))
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 22:06:19 +08:00
|
|
|
|
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.scheduling_mode && cfg.lru_enabled !== false)) {
|
|
|
|
|
|
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)
|
2026-03-05 23:22:25 +08:00
|
|
|
|
ordered.push(buildPresetListItem(def, ci.enabled !== false, ci.mode))
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
insertMissingByPreferredOrder(ordered, seen, defs, defsByName)
|
|
|
|
|
|
return reorderDistributionGroup(ordered)
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-03-05 23:22:25 +08:00
|
|
|
|
ordered.push(buildPresetListItem(lruDef, lruEnabled))
|
2026-03-04 22:06:19 +08:00
|
|
|
|
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)
|
2026-03-05 23:22:25 +08:00
|
|
|
|
ordered.push(buildPresetListItem(def, true, undefined))
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
insertMissingByPreferredOrder(ordered, seen, defs, defsByName)
|
|
|
|
|
|
return reorderDistributionGroup(ordered)
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 09:53:23 +08:00
|
|
|
|
function normalizeMutexSelection(items: PresetListItem[]): PresetListItem[] {
|
|
|
|
|
|
const next = [...items]
|
|
|
|
|
|
const groups = new Map<string, number[]>()
|
|
|
|
|
|
|
|
|
|
|
|
next.forEach((item, index) => {
|
|
|
|
|
|
if (!item.mutexGroup) return
|
|
|
|
|
|
if (!groups.has(item.mutexGroup)) groups.set(item.mutexGroup, [])
|
|
|
|
|
|
groups.get(item.mutexGroup)?.push(index)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
for (const indexes of groups.values()) {
|
|
|
|
|
|
if (indexes.length <= 1) continue
|
|
|
|
|
|
const enabledApplicable = indexes.find(index => {
|
|
|
|
|
|
const item = next[index]
|
|
|
|
|
|
return item.enabled && item.applicable
|
|
|
|
|
|
})
|
|
|
|
|
|
const firstApplicable = indexes.find(index => next[index].applicable)
|
|
|
|
|
|
const winner = enabledApplicable ?? firstApplicable ?? indexes[0]
|
|
|
|
|
|
indexes.forEach((index) => {
|
|
|
|
|
|
next[index].enabled = index === winner && next[index].applicable
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return next
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 09:53:23 +08:00
|
|
|
|
function togglePreset(index: number, enabled: boolean) {
|
|
|
|
|
|
const item = presetList.value[index]
|
|
|
|
|
|
if (!item) return
|
2026-03-05 23:22:25 +08:00
|
|
|
|
item.enabled = enabled
|
|
|
|
|
|
}
|
2026-03-05 09:53:23 +08:00
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
function selectDistribution(_anchorIndex: number, presetName: string) {
|
|
|
|
|
|
presetList.value.forEach(item => {
|
|
|
|
|
|
if (item.mutexGroup === DISTRIBUTION_GROUP) {
|
|
|
|
|
|
item.enabled = item.preset === presetName && item.applicable
|
2026-03-05 09:53:23 +08:00
|
|
|
|
}
|
2026-03-05 23:22:25 +08:00
|
|
|
|
})
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 09:53:23 +08:00
|
|
|
|
function setPresetModeByPreset(preset: string, mode: string) {
|
|
|
|
|
|
const targetIndex = presetList.value.findIndex(item => item.preset === preset)
|
|
|
|
|
|
if (targetIndex < 0) return
|
|
|
|
|
|
presetList.value[targetIndex].mode = mode
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
const distributionItems = computed(() => {
|
|
|
|
|
|
const items: { index: number; item: PresetListItem }[] = []
|
2026-03-05 09:53:23 +08:00
|
|
|
|
presetList.value.forEach((item, index) => {
|
2026-03-05 23:22:25 +08:00
|
|
|
|
if (item.mutexGroup === DISTRIBUTION_GROUP) {
|
|
|
|
|
|
items.push({ index, item })
|
|
|
|
|
|
}
|
2026-03-05 09:53:23 +08:00
|
|
|
|
})
|
2026-03-05 23:22:25 +08:00
|
|
|
|
return items
|
2026-03-05 09:53:23 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
const activeDistributionPreset = computed(() => {
|
|
|
|
|
|
const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
|
|
|
|
|
|
return found?.item.preset ?? null
|
|
|
|
|
|
})
|
2026-03-05 09:53:23 +08:00
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
const activeDistributionDesc = computed(() => {
|
|
|
|
|
|
const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
|
|
|
|
|
|
return found?.item.desc ?? null
|
|
|
|
|
|
})
|
2026-03-05 09:53:23 +08:00
|
|
|
|
|
2026-03-05 23:22:25 +08:00
|
|
|
|
const strategyItems = computed(() => {
|
|
|
|
|
|
const items: { index: number; item: PresetListItem }[] = []
|
|
|
|
|
|
presetList.value.forEach((item, index) => {
|
|
|
|
|
|
if (!item.mutexGroup) {
|
|
|
|
|
|
items.push({ index, item })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return items
|
|
|
|
|
|
})
|
2026-03-05 09:53:23 +08:00
|
|
|
|
|
2026-03-04 22:06:19 +08:00
|
|
|
|
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, async (open) => {
|
|
|
|
|
|
if (!open) return
|
|
|
|
|
|
await ensurePresetDefsLoaded()
|
2026-03-05 09:53:23 +08:00
|
|
|
|
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
|
2026-03-04 22:06:19 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSave() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
2026-03-05 09:53:23 +08:00
|
|
|
|
presetList.value = normalizeMutexSelection(presetList.value)
|
2026-03-04 22:06:19 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-05 15:15:26 +08:00
|
|
|
|
const mergedAdvanced: Record<string, unknown> = {
|
|
|
|
|
|
...(props.currentConfig ?? {}),
|
|
|
|
|
|
scheduling_presets: schedulingPresets,
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
2026-03-05 15:15:26 +08:00
|
|
|
|
const payload: Parameters<typeof updateProvider>[1] = {
|
|
|
|
|
|
pool_advanced: mergedAdvanced as PoolAdvancedConfig,
|
2026-03-04 22:06:19 +08:00
|
|
|
|
}
|
2026-03-05 09:53:23 +08:00
|
|
|
|
const updatedProvider = await updateProvider(props.providerId, payload)
|
2026-03-05 23:22:25 +08:00
|
|
|
|
|
2026-03-04 22:06:19 +08:00
|
|
|
|
success('号池调度已保存')
|
2026-03-05 09:53:23 +08:00
|
|
|
|
emit('saved', updatedProvider)
|
2026-03-04 22:06:19 +08:00
|
|
|
|
emit('update:modelValue', false)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(parseApiError(err))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|