Files
Aether/frontend/src/views/admin/RoutingProfiles.vue
T

1469 lines
53 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageContainer>
<section
v-if="!isDetailView"
class="mt-6"
>
<TableCard class="overflow-hidden">
<template #header>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-sm font-semibold">
策略分组
</h2>
<p class="mt-1 text-xs text-muted-foreground">
{{ groups.length }}
</p>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="loading"
aria-label="新建策略"
title="新建策略"
@click="goToCreate"
>
<Plus class="h-4 w-4" />
</Button>
</div>
</template>
<div>
<Table class="hidden lg:table">
<TableHeader>
<TableRow>
<TableHead class="w-[28%]">
策略分组
</TableHead>
<TableHead class="w-[120px]">
状态
</TableHead>
<TableHead class="w-[120px]">
维度
</TableHead>
<TableHead class="w-[140px]">
模型范围
</TableHead>
<TableHead class="w-[140px]">
默认策略
</TableHead>
<TableHead class="w-[180px]">
更新时间
</TableHead>
<TableHead class="w-[80px] text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<TableCell
colspan="7"
class="py-10 text-center text-sm text-muted-foreground"
>
正在加载调度策略
</TableCell>
</TableRow>
<TableRow v-else-if="groups.length === 0">
<TableCell
colspan="7"
class="py-10 text-center text-sm text-muted-foreground"
>
暂无调度策略可以先创建一个默认分组
</TableCell>
</TableRow>
<TableRow
v-for="group in groups"
v-else
:key="group.id"
class="cursor-pointer hover:bg-muted/50"
@click="openGroup(group)"
>
<TableCell>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate font-medium">{{ group.name }}</span>
<Badge
v-if="group.is_system_default"
variant="secondary"
class="shrink-0"
>
系统默认
</Badge>
</div>
<p class="mt-1 line-clamp-1 text-xs text-muted-foreground">
{{ group.description || '未填写描述' }}
</p>
</div>
</TableCell>
<TableCell>
<Badge :variant="group.enabled ? 'default' : 'secondary'">
{{ group.enabled ? '启用' : '停用' }}
</Badge>
</TableCell>
<TableCell>
{{ groupSortingScopeLabel(group) }}
</TableCell>
<TableCell>
{{ groupModelScopeLabel(group) }}
</TableCell>
<TableCell>
{{ groupSchedulingSummary(group) }}
</TableCell>
<TableCell class="text-muted-foreground">
{{ formatUnixSeconds(group.updated_at) }}
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="配置策略"
@click.stop="openGroup(group)"
>
<ChevronRight class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<div
v-if="loading"
class="py-10 text-center text-sm text-muted-foreground lg:hidden"
>
正在加载调度策略
</div>
<div
v-else-if="groups.length === 0"
class="px-4 py-10 text-center text-sm text-muted-foreground lg:hidden"
>
暂无调度策略可以先创建一个默认分组
</div>
<div
v-else
class="divide-y divide-border/40 lg:hidden"
>
<button
v-for="group in groups"
:key="group.id"
type="button"
class="flex w-full items-start justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/50"
@click="openGroup(group)"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate text-sm font-medium">{{ group.name }}</span>
<Badge :variant="group.enabled ? 'default' : 'secondary'">
{{ group.enabled ? '启用' : '停用' }}
</Badge>
<Badge
v-if="group.is_system_default"
variant="secondary"
>
系统默认
</Badge>
</div>
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">
{{ group.description || '未填写描述' }}
</p>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{{ groupSortingScopeLabel(group) }}</span>
<span>{{ groupModelScopeLabel(group) }}</span>
<span>{{ groupSchedulingSummary(group) }}</span>
</div>
</div>
<ChevronRight class="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</div>
</div>
</TableCard>
</section>
<section
v-else
class="mt-6"
>
<Card
v-if="draft"
class="overflow-hidden"
>
<div class="border-b border-border/60 px-5 py-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-base font-semibold">
{{ isCreating ? '新建调度策略' : draft.name || '未命名策略' }}
</h2>
<Badge
v-if="draft.is_system_default"
variant="secondary"
>
系统默认
</Badge>
<Badge :variant="draft.enabled ? 'default' : 'secondary'">
{{ draft.enabled ? '启用' : '停用' }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
更新时间 {{ formatUnixSeconds(draft.updated_at) }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="draft.is_system_default
? 'text-primary hover:text-primary'
: 'text-muted-foreground/70 hover:text-foreground'"
:aria-label="draft.is_system_default ? '系统默认' : '设为系统默认'"
:title="draft.is_system_default ? '系统默认' : '设为系统默认'"
@click="draft.is_system_default = !draft.is_system_default"
>
<Star class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground/70 hover:text-foreground"
:disabled="!canSaveDraft"
aria-label="保存"
title="保存"
@click="saveDraft"
>
<Save
class="h-4 w-4"
:class="{ 'animate-pulse': saving }"
/>
</Button>
<Button
v-if="!isCreating"
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground/70 hover:text-destructive"
:disabled="deleting"
aria-label="删除"
title="删除"
@click="deleteDraft"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div class="space-y-6 p-5">
<div class="grid gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.2fr)_180px_320px]">
<label class="space-y-1 text-sm">
<span class="text-muted-foreground">名称</span>
<Input
v-model="draft.name"
placeholder="新调度策略"
/>
</label>
<label class="space-y-1 text-sm">
<span class="text-muted-foreground">描述</span>
<Input
v-model="draft.description"
placeholder="例如:默认策略 / 高推理策略 / 号池优先策略"
/>
</label>
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">状态</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="draft.enabled
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setDraftEnabled(true)"
>
启用
</button>
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="!draft.enabled
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setDraftEnabled(false)"
>
停用
</button>
</div>
</div>
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">
维度
</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="sortingScope === 'unified'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setSortingScope('unified')"
>
统一调度
</button>
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="sortingScope === 'per_model'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setSortingScope('per_model')"
>
区分模型
</button>
</div>
</div>
</div>
<section
class="space-y-3 rounded-lg border border-border/60 p-4"
aria-labelledby="model-allowlist-heading"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<h3
id="model-allowlist-heading"
class="text-sm font-medium"
>
模型白名单
</h3>
<Badge :variant="draft.config_json.allowed_models.length ? 'outline' : 'secondary'">
{{ draft.config_json.allowed_models.length ? `${draft.config_json.allowed_models.length} 项` : '全部模型' }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
控制此策略分组适用于哪些模型留空表示全部模型它与区分模型中的专属调度覆盖相互独立支持精确值* 和前缀通配符 gpt-*),多个值用英文逗号或换行分隔
</p>
</div>
<Button
v-if="draft.config_json.allowed_models.length"
type="button"
variant="ghost"
size="sm"
class="shrink-0 text-muted-foreground hover:text-foreground"
data-testid="clear-allowed-models"
@click="clearAllowedModelScope"
>
改为全部模型
</Button>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<Input
v-model="allowedModelsInput"
class="min-w-0 flex-1"
data-testid="allowed-models-input"
aria-label="模型白名单"
placeholder="留空表示全部模型,例如:gpt-5, claude-*, legacy-model"
/>
<Button
type="button"
variant="outline"
class="shrink-0"
data-testid="apply-allowed-models"
@click="applyAllowedModelScope"
>
应用范围
</Button>
</div>
<div
v-if="draft.config_json.allowed_models.length"
class="flex flex-wrap gap-2"
data-testid="allowed-model-values"
>
<Badge
v-for="(model, index) in draft.config_json.allowed_models"
:key="`${model}:${index}`"
variant="outline"
class="font-mono font-normal"
>
{{ model }}
</Badge>
</div>
<div
v-if="allowedModelsLookLikeLegacyMirror"
class="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-muted-foreground"
data-testid="allowed-models-legacy-mirror"
>
当前白名单与按模型策略列表一致可能来自旧版界面的联动保存现有范围会原样保留如需让其他模型也使用默认策略请显式点击改为全部模型”。
</div>
</section>
<section
v-if="sortingScope === 'unified'"
class="space-y-4"
>
<div class="space-y-3 rounded-lg border border-border/60 p-4">
<div>
<h3 class="text-sm font-medium">
优先级模式与调度策略
</h3>
<p class="mt-1 text-xs text-muted-foreground">
统一作用于策略范围内的全部模型
</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">优先级模式</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="firstStepPriorityMode === 'provider'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateFirstStepPriorityMode('provider')"
>
<Layers class="h-4 w-4" />
Provider
</button>
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="firstStepPriorityMode === 'global_key'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateFirstStepPriorityMode('global_key')"
>
<Key class="h-4 w-4" />
Key
</button>
</div>
</div>
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">调度策略</span>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="firstStepSchedulingMode === mode.value
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateFirstStepSchedulingMode(mode.value)"
>
{{ mode.label }}
</button>
</div>
</div>
</div>
</div>
<RoutingPriorityPolicyEditor
:config="draft.config_json"
:model="DEFAULT_ROUTING_POLICY_MODEL"
:show-priority-mode="false"
:show-scheduling-mode="false"
subtitle="统一作用于当前策略范围内的所有模型"
@update:config="updateDraftConfig"
/>
</section>
<section v-else>
<div class="flex max-h-[560px] flex-col gap-3 overflow-hidden rounded-lg border border-border/60 p-3">
<div class="grid grid-cols-2 gap-3">
<Input
v-model="globalModelSearch"
placeholder="搜索模型"
class="w-full"
/>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1 text-xs">
<button
v-for="filter in modelFilters"
:key="filter.value"
type="button"
class="h-9 rounded-md px-3 font-medium transition-colors"
:class="modelFilter === filter.value
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="modelFilter = filter.value"
>
{{ filter.label }}
</button>
</div>
</div>
<div
v-if="loadingGlobalModels"
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
>
正在加载模型
</div>
<div
v-else-if="globalModelsError"
class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
>
{{ globalModelsError }}
</div>
<div
v-else-if="modelRows.length === 0"
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
>
{{ globalModelSearch.trim() ? '未匹配到模型' : modelFilter === 'configured' ? '暂无已配置模型' : '暂无未配置模型' }}
</div>
<div
v-else
class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
>
<div
v-for="row in modelRows"
:key="row.name"
class="rounded-lg border transition-colors"
:class="selectedPerModelName === row.name
? 'border-primary/50 bg-primary/5'
: 'border-border/60'"
>
<div class="flex w-full items-center gap-3 px-4 py-3">
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 text-left text-sm"
@click="selectGlobalModel(row.name)"
>
<span
v-if="row.configured"
class="h-2 w-2 shrink-0 rounded-full bg-primary"
aria-hidden="true"
/>
<Plus
v-else
class="h-3.5 w-3.5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">{{ row.displayName }}</span>
<span class="block truncate text-xs text-muted-foreground">{{ row.name }}</span>
</span>
</button>
<template v-if="selectedPerModelName === row.name && activePerModelPolicy">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
:disabled="copySourceCandidates.length === 0"
title="加载其他模型配置"
>
<Copy class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="max-h-[320px] overflow-y-auto"
>
<DropdownMenuItem
v-for="source in copySourceCandidates"
:key="source.model"
@select="copyModelConfig(source.model)"
>
<span class="min-w-0">
<span class="block truncate text-sm font-medium">{{ source.label }}</span>
<span class="block truncate text-xs text-muted-foreground">{{ source.model }}</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
:disabled="!canSaveCurrentModel"
title="保存到草稿"
@click="saveCurrentModel"
>
<Save class="h-4 w-4" />
</Button>
<Button
v-if="hasModelPolicy(activePerModelPolicy.model)"
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="canRemoveCurrentModel ? 'text-muted-foreground/70 hover:text-destructive' : 'text-muted-foreground/30'"
:disabled="!canRemoveCurrentModel"
:title="canRemoveCurrentModel ? '移除当前模型排序' : '当前有未保存改动,不能移除'"
@click="removePerModelPolicy(activePerModelPolicy.model)"
>
<Trash2 class="h-4 w-4" />
</Button>
</template>
<button
type="button"
class="shrink-0"
@click="selectGlobalModel(row.name)"
>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="selectedPerModelName === row.name ? 'rotate-180' : ''"
/>
</button>
</div>
<div
v-if="selectedPerModelName === row.name && activePerModelPolicy"
class="border-t border-border/60 p-4"
>
<div class="mb-4 space-y-3 rounded-lg border border-border/60 p-4">
<h3 class="text-sm font-medium">
优先级模式与调度策略
</h3>
<div class="grid gap-3 lg:grid-cols-2">
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">优先级模式</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="modelPriorityMode(activePerModelPolicy.model) === 'provider'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateModelPriorityMode(activePerModelPolicy.model, 'provider')"
>
<Layers class="h-4 w-4" />
Provider
</button>
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="modelPriorityMode(activePerModelPolicy.model) === 'global_key'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateModelPriorityMode(activePerModelPolicy.model, 'global_key')"
>
<Key class="h-4 w-4" />
Key
</button>
</div>
</div>
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">调度策略</span>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="modelSchedulingMode(activePerModelPolicy.model) === mode.value
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="updateModelSchedulingMode(activePerModelPolicy.model, mode.value)"
>
{{ mode.label }}
</button>
</div>
</div>
</div>
</div>
<RoutingPriorityPolicyEditor
:config="activeConfigForReading"
:model="activePerModelPolicy.model"
:priority-mode="modelPriorityMode(activePerModelPolicy.model)"
:scheduling-mode="modelSchedulingMode(activePerModelPolicy.model)"
:show-priority-mode="false"
:show-scheduling-mode="false"
:subtitle="`仅作用于 ${activePerModelPolicy.model}`"
@update:config="updateEditingConfig"
@update:priority-mode="mode => updateModelPriorityMode(activePerModelPolicy.model, mode)"
@update:scheduling-mode="mode => updateModelSchedulingMode(activePerModelPolicy.model, mode)"
/>
</div>
</div>
</div>
</div>
</section>
</div>
</Card>
<Card
v-else
class="flex min-h-[360px] items-center justify-center p-8 text-center"
>
<div>
<SlidersHorizontal class="mx-auto h-8 w-8 text-muted-foreground" />
<p class="mt-3 text-sm font-medium">
{{ loading ? '正在加载调度策略' : '未找到调度策略' }}
</p>
<Button
v-if="!loading"
variant="outline"
class="mt-4"
@click="goToList"
>
返回分组
</Button>
</div>
</Card>
</section>
<AlertDialog
v-model="switchModelDialogOpen"
type="warning"
title="切换模型"
description="当前模型有未保存的改动切换将丢弃这些改动是否继续"
confirm-text="继续"
@confirm="confirmSwitchModel"
@cancel="cancelSwitchModel"
/>
<AlertDialog
v-model="deleteDialogOpen"
type="destructive"
title="删除调度策略"
:description="`确认删除调度策略「${draft?.name ?? ''}」?此操作无法撤销。`"
confirm-text="删除"
:loading="deleting"
@confirm="confirmDeleteDraft"
/>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ChevronDown, ChevronRight, Copy, Key, Layers, Plus, Save, Star, Trash2 } from 'lucide-vue-next'
import { PageContainer } from '@/components/layout'
import { Badge, Button, Card, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { AlertDialog } from '@/components/common'
import {
DEFAULT_ROUTING_POLICY_MODEL,
allowedModelsMirrorPerModelPolicies,
clearAllowedModels,
copyPerModelRoutingConfig,
createEmptyModelPolicy,
createEmptyRoutingGroupConfig,
formatAllowedModelsInput,
getModelScheduling,
isGeneratedModelSchedulingRule,
modelSchedulingRuleId,
normalizeRoutingGroupConfig,
removePerModelRoutingConfig,
routingModelScopeLabel,
savePerModelRoutingConfig,
setRoutingSortingScope,
updateAllowedModelsFromInput,
upsertModelSchedulingRule,
type RoutingGroupConfig,
type RoutingPriorityMode,
type RoutingSchedulingMode,
type RoutingSortingScope,
} from '@/features/routing/utils/routingPolicy'
import { RoutingPriorityPolicyEditor } from '@/features/routing/components'
import {
createRoutingGroup,
deleteRoutingGroup,
listRoutingGroups,
updateRoutingGroup,
type RoutingGroupRecord,
} from '@/api/routing-profiles'
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
interface RoutingGroupDraft {
id?: string
name: string
description: string
enabled: boolean
is_system_default: boolean
config_json: RoutingGroupConfig
version: number
updated_at?: number | null
}
type ModelFilter = 'configured' | 'unconfigured'
const modelFilters: Array<{ value: ModelFilter; label: string }> = [
{ value: 'unconfigured', label: '未配置' },
{ value: 'configured', label: '已配置' },
]
const { success, error: showError } = useToast()
const route = useRoute()
const router = useRouter()
const schedulingModes: Array<{ value: RoutingSchedulingMode; label: string }> = [
{ value: 'cache_affinity', label: '缓存亲和' },
{ value: 'load_balance', label: '负载均衡' },
{ value: 'fixed_order', label: '固定顺序' },
]
const groups = ref<RoutingGroupRecord[]>([])
const selectedGroupId = ref<string | null>(null)
const draft = ref<RoutingGroupDraft | null>(null)
const savedDraftSnapshot = ref<string | null>(null)
const sortingScope = ref<RoutingSortingScope>('unified')
const selectedPerModelName = ref<string | null>(null)
const editingConfig = ref<RoutingGroupConfig | null>(null)
const allowedModelsInput = ref('')
const globalModelSearch = ref('')
const modelFilter = ref<ModelFilter>('unconfigured')
const globalModels = ref<GlobalModelResponse[]>([])
const loadingGlobalModels = ref(false)
const globalModelsError = ref<string | null>(null)
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const isCreating = ref(false)
const switchModelTarget = ref<string | null>(null)
const switchModelDialogOpen = ref(false)
const deleteDialogOpen = ref(false)
const isCreateRoute = computed(() => route.name === 'RoutingProfileCreate')
const routeGroupId = computed(() => paramToString(route.params.groupId))
const isDetailView = computed(() => isCreateRoute.value || route.name === 'RoutingProfileDetail')
const perModelPolicies = computed(() => {
return draft.value?.config_json.model_policies
.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL)
?? []
})
const activePerModelPolicy = computed(() => {
if (!selectedPerModelName.value) return null
const existing = perModelPolicies.value.find(policy => policy.model === selectedPerModelName.value)
if (existing) return existing
return createEmptyModelPolicy(selectedPerModelName.value)
})
const firstStepPriorityMode = computed<RoutingPriorityMode>(() => {
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
return modelPriorityMode(activePerModelPolicy.value.model)
}
return draft.value?.config_json.default_policy.priority_mode ?? 'provider'
})
const firstStepSchedulingMode = computed<RoutingSchedulingMode>(() => {
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
return modelSchedulingMode(activePerModelPolicy.value.model)
}
return draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity'
})
const allowedModelsLookLikeLegacyMirror = computed(() => {
return draft.value
? allowedModelsMirrorPerModelPolicies(draft.value.config_json)
: false
})
interface ModelRow {
name: string
displayName: string
configured: boolean
}
const modelRows = computed<ModelRow[]>(() => {
const query = globalModelSearch.value.trim().toLowerCase()
const seen = new Set<string>()
const rows: ModelRow[] = []
for (const policy of perModelPolicies.value) {
const name = policy.model
const found = globalModels.value.find(item => item.name === name)
rows.push({
name,
displayName: found?.display_name || name,
configured: true,
})
seen.add(name)
}
for (const model of globalModels.value) {
if (seen.has(model.name)) continue
rows.push({
name: model.name,
displayName: model.display_name || model.name,
configured: false,
})
}
return rows
.filter(row => {
if (modelFilter.value === 'configured' && !row.configured) return false
if (modelFilter.value === 'unconfigured' && row.configured) return false
if (!query) return true
return (
row.name.toLowerCase().includes(query)
|| row.displayName.toLowerCase().includes(query)
)
})
.sort((left, right) => {
if (left.configured !== right.configured) {
return left.configured ? -1 : 1
}
return left.name.localeCompare(right.name)
})
})
function normalizeRecord(group: RoutingGroupRecord): RoutingGroupRecord {
return {
...group,
config_json: normalizeRoutingGroupConfig(group.config_json),
}
}
function cloneConfig(config: RoutingGroupConfig): RoutingGroupConfig {
return normalizeRoutingGroupConfig(JSON.parse(JSON.stringify(config)) as Partial<RoutingGroupConfig>)
}
function draftSnapshotValue(value: RoutingGroupDraft): string {
return JSON.stringify({
name: value.name.trim(),
description: value.description.trim() || null,
enabled: value.enabled,
is_system_default: value.is_system_default,
config_json: cloneConfig(value.config_json),
})
}
function buildDraft(group: RoutingGroupRecord): RoutingGroupDraft {
return {
id: group.id,
name: group.name,
description: group.description ?? '',
enabled: group.enabled,
is_system_default: group.is_system_default,
config_json: cloneConfig(group.config_json),
version: group.version,
updated_at: group.updated_at,
}
}
function paramToString(value: unknown): string | null {
if (Array.isArray(value)) return value[0] ?? null
return typeof value === 'string' ? value : null
}
function clearDraftState(): void {
isCreating.value = false
selectedGroupId.value = null
draft.value = null
savedDraftSnapshot.value = null
selectedPerModelName.value = null
editingConfig.value = null
allowedModelsInput.value = ''
switchModelTarget.value = null
switchModelDialogOpen.value = false
deleteDialogOpen.value = false
}
function selectGroup(group: RoutingGroupRecord): void {
const normalized = normalizeRecord(group)
isCreating.value = false
selectedGroupId.value = normalized.id
draft.value = buildDraft(normalized)
allowedModelsInput.value = formatAllowedModelsInput(draft.value.config_json.allowed_models)
savedDraftSnapshot.value = draftSnapshotValue(draft.value)
syncEditorStateFromConfig(draft.value.config_json)
resetEditingConfig()
}
function setDraftEnabled(value: boolean): void {
if (!draft.value) return
draft.value.enabled = value
}
function startCreate(): void {
isCreating.value = true
selectedGroupId.value = null
draft.value = {
name: '新调度策略',
description: '',
enabled: false,
is_system_default: groups.value.length === 0,
config_json: createEmptyRoutingGroupConfig(),
version: 1,
updated_at: null,
}
savedDraftSnapshot.value = null
allowedModelsInput.value = ''
syncEditorStateFromConfig(draft.value.config_json)
resetEditingConfig()
}
function syncRouteState(): void {
if (!isDetailView.value) {
clearDraftState()
return
}
if (isCreateRoute.value) {
if (!isCreating.value || !draft.value || draft.value.id) {
startCreate()
}
return
}
const groupId = routeGroupId.value
if (!groupId) {
clearDraftState()
return
}
const group = groups.value.find(item => item.id === groupId)
if (!group) {
clearDraftState()
selectedGroupId.value = groupId
return
}
if (isCreating.value || selectedGroupId.value !== group.id || !draft.value) {
selectGroup(group)
}
}
function goToList(): void {
void router.push({ name: 'RoutingProfiles' })
}
function goToCreate(): void {
void router.push({ name: 'RoutingProfileCreate' })
}
function openGroup(group: RoutingGroupRecord): void {
void router.push({ name: 'RoutingProfileDetail', params: { groupId: group.id } })
}
function schedulingModeLabel(mode: RoutingSchedulingMode): string {
return schedulingModes.find(item => item.value === mode)?.label ?? mode
}
function groupSortingScopeLabel(group: RoutingGroupRecord): string {
return hasPerModelSorting(normalizeRoutingGroupConfig(group.config_json)) ? '区分模型' : '统一调度'
}
function groupModelScopeLabel(group: RoutingGroupRecord): string {
return routingModelScopeLabel(group.config_json)
}
function groupSchedulingSummary(group: RoutingGroupRecord): string {
const config = normalizeRoutingGroupConfig(group.config_json)
if (hasPerModelSorting(config)) return '按模型配置'
return schedulingModeLabel(config.default_policy.scheduling_mode)
}
function updateDraftConfig(value: RoutingGroupConfig): void {
if (!draft.value) return
draft.value.config_json = normalizeRoutingGroupConfig(value)
syncSelectedPerModelPolicy()
}
function resetEditingConfig(): void {
if (!draft.value) {
editingConfig.value = null
return
}
editingConfig.value = cloneConfig(draft.value.config_json)
}
function updateEditingConfig(value: RoutingGroupConfig): void {
editingConfig.value = normalizeRoutingGroupConfig(value)
}
const editingDirty = computed(() => {
if (!editingConfig.value || !draft.value) return false
return JSON.stringify(editingConfig.value) !== JSON.stringify(draft.value.config_json)
})
const draftDirty = computed(() => {
if (!draft.value) return false
if (isCreating.value) return true
return savedDraftSnapshot.value !== draftSnapshotValue(draft.value)
})
const canSaveDraft = computed(() => {
const hasPendingCurrentModel = perModelEditingActive.value
&& Boolean(activePerModelPolicy.value)
&& (editingDirty.value || !currentModelPersisted.value)
return Boolean(draft.value)
&& !saving.value
&& draftDirty.value
&& !hasPendingCurrentModel
&& !(perModelEditingActive.value && perModelPolicies.value.length === 0)
})
const currentModelPersisted = computed(() => {
const model = activePerModelPolicy.value?.model
return model ? hasModelPolicy(model) : false
})
const canSaveCurrentModel = computed(() => {
return Boolean(activePerModelPolicy.value)
&& !saving.value
&& (editingDirty.value || !currentModelPersisted.value)
})
const canRemoveCurrentModel = computed(() => {
return Boolean(activePerModelPolicy.value)
&& currentModelPersisted.value
&& !saving.value
&& !editingDirty.value
})
function syncEditorStateFromConfig(config: RoutingGroupConfig): void {
const normalized = normalizeRoutingGroupConfig(config)
sortingScope.value = hasPerModelSorting(normalized) ? 'per_model' : 'unified'
syncSelectedPerModelPolicy()
}
function hasPerModelSorting(config: RoutingGroupConfig): boolean {
return config.model_policies.some(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL)
|| config.rules.some(isGeneratedModelSchedulingRule)
}
function setSortingScope(scope: RoutingSortingScope): void {
if (!draft.value) return
sortingScope.value = scope
if (scope === 'unified') {
const next = setRoutingSortingScope(draft.value.config_json, scope)
updateDraftConfig(next)
resetEditingConfig()
return
}
resetEditingConfig()
}
function updateFirstStepPriorityMode(mode: RoutingPriorityMode): void {
if (!draft.value) return
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
updateModelPriorityMode(activePerModelPolicy.value.model, mode)
return
}
updateDraftConfig({
...draft.value.config_json,
default_policy: {
...draft.value.config_json.default_policy,
priority_mode: mode,
},
})
}
function updateFirstStepSchedulingMode(mode: RoutingSchedulingMode): void {
if (!draft.value) return
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
updateModelSchedulingMode(activePerModelPolicy.value.model, mode)
return
}
updateDraftConfig({
...draft.value.config_json,
default_policy: {
...draft.value.config_json.default_policy,
scheduling_mode: mode,
},
})
}
function removePerModelPolicy(model: string): void {
if (!draft.value) return
if (perModelEditingActive.value && editingDirty.value) {
showError('请先保存当前改动后再移除模型')
return
}
const next = removePerModelRoutingConfig(draft.value.config_json, model)
if (selectedPerModelName.value === model) {
selectedPerModelName.value = null
}
modelFilter.value = 'unconfigured'
updateDraftConfig(next)
resetEditingConfig()
}
function selectGlobalModel(model: string): void {
if (!model) return
if (model === selectedPerModelName.value) {
resetEditingConfig()
selectedPerModelName.value = null
return
}
const shouldAddModel = !hasModelPolicy(model)
if (perModelEditingActive.value && editingDirty.value) {
switchModelTarget.value = model
switchModelDialogOpen.value = true
return
}
if (shouldAddModel) {
resetEditingConfig()
}
selectedPerModelName.value = model
}
function confirmSwitchModel(): void {
const target = switchModelTarget.value
if (target) {
resetEditingConfig()
selectedPerModelName.value = target
}
switchModelTarget.value = null
switchModelDialogOpen.value = false
}
function cancelSwitchModel(): void {
switchModelTarget.value = null
}
function hasModelPolicy(model: string): boolean {
if (perModelPolicies.value.some(policy => policy.model === model)) return true
const ruleId = modelSchedulingRuleId(model)
return draft.value?.config_json.rules.some(rule => rule.id === ruleId) ?? false
}
const copySourceCandidates = computed(() => {
if (!draft.value) return []
const current = selectedPerModelName.value
return perModelPolicies.value
.filter(policy => policy.model !== current)
.map(policy => ({
model: policy.model,
label: globalModelLabel(policy.model),
}))
})
function copyModelConfig(sourceModel: string): void {
if (!draft.value || !editingConfig.value) return
const target = selectedPerModelName.value
if (!target || target === sourceModel) return
const next = copyPerModelRoutingConfig(
editingConfig.value,
draft.value.config_json,
sourceModel,
target,
)
updateEditingConfig(next)
success(`已加载 ${globalModelLabel(sourceModel)} 的配置,点击保存生效`)
}
function syncSelectedPerModelPolicy(): void {
if (selectedPerModelName.value) return
const firstConfigured = perModelPolicies.value[0]?.model
selectedPerModelName.value = firstConfigured ?? null
}
const perModelEditingActive = computed(() => sortingScope.value === 'per_model')
const activeConfigForReading = computed<RoutingGroupConfig>(() => {
if (perModelEditingActive.value && editingConfig.value) return editingConfig.value
return draft.value?.config_json ?? createEmptyRoutingGroupConfig()
})
function modelPriorityMode(model: string): RoutingPriorityMode {
return getModelScheduling(activeConfigForReading.value, model).priority_mode
}
function modelSchedulingMode(model: string): RoutingSchedulingMode {
return getModelScheduling(activeConfigForReading.value, model).scheduling_mode
}
function updateModelPriorityMode(model: string, mode: RoutingPriorityMode): void {
if (!draft.value) return
const baseConfig = perModelEditingActive.value && editingConfig.value
? editingConfig.value
: draft.value.config_json
const current = getModelScheduling(baseConfig, model)
const next = upsertModelSchedulingRule(baseConfig, model, {
priority_mode: mode,
scheduling_mode: current.scheduling_mode,
})
if (perModelEditingActive.value) {
updateEditingConfig(next)
return
}
updateDraftConfig(next)
}
function updateModelSchedulingMode(model: string, mode: RoutingSchedulingMode): void {
if (!draft.value) return
const baseConfig = perModelEditingActive.value && editingConfig.value
? editingConfig.value
: draft.value.config_json
const current = getModelScheduling(baseConfig, model)
const next = upsertModelSchedulingRule(baseConfig, model, {
priority_mode: current.priority_mode,
scheduling_mode: mode,
})
if (perModelEditingActive.value) {
updateEditingConfig(next)
return
}
updateDraftConfig(next)
}
function globalModelLabel(modelName: string): string {
const model = globalModels.value.find(item => item.name === modelName)
if (!model) return modelName
if (!model.display_name || model.display_name === model.name) return model.name
return `${model.display_name} (${model.name})`
}
function applyAllowedModelScope(): void {
if (!draft.value) return
const next = updateAllowedModelsFromInput(draft.value.config_json, allowedModelsInput.value)
updateDraftConfig(next)
if (editingConfig.value) {
editingConfig.value = updateAllowedModelsFromInput(editingConfig.value, allowedModelsInput.value)
}
allowedModelsInput.value = formatAllowedModelsInput(next.allowed_models)
}
function clearAllowedModelScope(): void {
if (!draft.value) return
const next = clearAllowedModels(draft.value.config_json)
updateDraftConfig(next)
if (editingConfig.value) {
editingConfig.value = clearAllowedModels(editingConfig.value)
}
allowedModelsInput.value = ''
}
function replaceGroup(group: RoutingGroupRecord): void {
const normalized = normalizeRecord(group)
const index = groups.value.findIndex(item => item.id === normalized.id)
if (index >= 0) {
groups.value[index] = normalized
} else {
groups.value.unshift(normalized)
}
selectGroup(normalized)
}
async function fetchGroups(): Promise<void> {
loading.value = true
try {
const response = await listRoutingGroups()
groups.value = response.items.map(normalizeRecord)
} catch (err) {
showError(parseApiError(err, '加载调度策略失败'))
log.error('加载调度策略失败:', err)
} finally {
loading.value = false
syncRouteState()
}
}
async function loadGlobalModels(options: { cacheTtlMs?: number } = {}): Promise<void> {
loadingGlobalModels.value = true
globalModelsError.value = null
try {
const response = await getGlobalModels(
{ limit: 1000, is_active: true },
{ cacheTtlMs: options.cacheTtlMs ?? 0 },
)
globalModels.value = response.models ?? []
} catch (err) {
globalModels.value = []
globalModelsError.value = parseApiError(err, '加载全局模型失败')
log.error('加载全局模型失败:', err)
} finally {
loadingGlobalModels.value = false
}
}
async function saveDraft(): Promise<void> {
if (!draft.value) return
const name = draft.value.name.trim()
if (!name) {
showError('策略名称不能为空')
return
}
const config = cloneConfig(draft.value.config_json)
if (sortingScope.value === 'per_model' && perModelPolicies.value.length === 0) {
showError('按模型排序时至少选择一个模型')
return
}
saving.value = true
try {
const payload = {
name,
description: draft.value.description.trim() || null,
enabled: draft.value.enabled,
is_system_default: draft.value.is_system_default,
config_json: config,
}
const wasCreating = isCreating.value || !draft.value.id
const saved = wasCreating
? await createRoutingGroup(payload)
: await updateRoutingGroup(draft.value.id, payload)
isCreating.value = false
replaceGroup(saved)
if (wasCreating) {
await router.replace({ name: 'RoutingProfileDetail', params: { groupId: saved.id } })
}
success('调度策略已保存')
} catch (err) {
showError(parseApiError(err, '保存调度策略失败'))
log.error('保存调度策略失败:', err)
} finally {
saving.value = false
}
}
function saveCurrentModel(): void {
if (!draft.value || !editingConfig.value) return
const model = selectedPerModelName.value
if (!model) {
showError('请先选择模型')
return
}
const next = savePerModelRoutingConfig(editingConfig.value, model)
updateDraftConfig(next)
modelFilter.value = 'configured'
resetEditingConfig()
success('当前模型配置已保存到草稿,点击外层保存后生效')
}
function deleteDraft(): void {
if (!draft.value?.id) return
deleteDialogOpen.value = true
}
async function confirmDeleteDraft(): Promise<void> {
if (!draft.value?.id) return
deleting.value = true
try {
const deletedId = draft.value.id
await deleteRoutingGroup(deletedId)
groups.value = groups.value.filter(group => group.id !== deletedId)
clearDraftState()
await router.replace({ name: 'RoutingProfiles' })
success('调度策略已删除')
deleteDialogOpen.value = false
} catch (err) {
showError(parseApiError(err, '删除调度策略失败'))
log.error('删除调度策略失败:', err)
} finally {
deleting.value = false
}
}
function formatUnixSeconds(value?: number | null): string {
if (!value) return '-'
return new Date(value * 1000).toLocaleString('zh-CN')
}
onMounted(() => {
void fetchGroups()
void loadGlobalModels({ cacheTtlMs: 60_000 })
})
watch(
() => [route.name, route.params.groupId],
() => syncRouteState(),
)
</script>