mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(pool): 新增调度维度、互斥组机制、健康策略扩展与配额刷新增强
- 新增 priority_first/health_first/latency_first/cost_first 四个调度维度 - 引入 mutex_group 互斥组机制,lru 与 single_account 归入 distribution_mode - 维度 compute_metric 签名扩展 context 参数,支持获取 cost_totals 等上下文 - 各维度增加 evidence_hint 字段描述评分依据 - 健康策略扩展 408/409/423/425/5xx 瞬态状态码冷却,403 按 body 分级冷却 - Codex 配额刷新增强 401/402/403 错误处理,402 生成 fallback 元数据 - 前端号池管理支持账号优先级内联编辑与互斥维度切换 UI - 列表排序改为 internal_priority + created_at,移除 sticky_counts 查询
This commit is contained in:
@@ -87,6 +87,8 @@ export interface PoolPresetMeta {
|
||||
providers: string[]
|
||||
modes?: PoolPresetModeMeta[] | null
|
||||
default_mode?: string | null
|
||||
mutex_group?: string | null
|
||||
evidence_hint?: string | null
|
||||
}
|
||||
|
||||
export interface PoolKeyDetail {
|
||||
|
||||
@@ -21,10 +21,11 @@
|
||||
<div class="space-y-0.5">
|
||||
<div
|
||||
v-for="(item, index) in presetList"
|
||||
v-show="!isMutexFollower(index)"
|
||||
:key="item.preset"
|
||||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
|
||||
:class="[
|
||||
!item.applicable
|
||||
!displayItems[index].applicable
|
||||
? 'border-border/30 bg-muted/20 opacity-50'
|
||||
: draggedIndex === index
|
||||
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
||||
@@ -32,25 +33,45 @@
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
||||
]"
|
||||
:draggable="item.applicable"
|
||||
@dragstart="item.applicable && handleDragStart(index, $event)"
|
||||
:draggable="canDragPreset(index)"
|
||||
@dragstart="canDragPreset(index) && handleDragStart(index, $event)"
|
||||
@dragend="handleDragEnd"
|
||||
@dragover.prevent="item.applicable && handleDragOver(index)"
|
||||
@dragover.prevent="canDragPreset(index) && handleDragOver(index)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="item.applicable && handleDrop(index)"
|
||||
@drop="canDragPreset(index) && handleDrop(index)"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
class="p-1 rounded transition-colors shrink-0"
|
||||
:class="item.applicable
|
||||
:class="canDragPreset(index)
|
||||
? '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>
|
||||
|
||||
<!-- Enable/disable switch -->
|
||||
<template v-if="item.mutexGroup">
|
||||
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md shrink-0">
|
||||
<button
|
||||
v-for="member in getMutexGroupItems(index)"
|
||||
:key="member.preset"
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
|
||||
:disabled="!member.applicable"
|
||||
:class="[
|
||||
member.preset === displayItems[index].preset
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50',
|
||||
!member.applicable ? 'opacity-40 cursor-not-allowed' : ''
|
||||
]"
|
||||
@click="selectMutexPreset(index, member.preset)"
|
||||
>
|
||||
{{ member.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<Switch
|
||||
v-else
|
||||
:model-value="item.enabled"
|
||||
:disabled="!item.applicable"
|
||||
@update:model-value="(v: boolean) => togglePreset(index, v)"
|
||||
@@ -61,35 +82,41 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="!item.applicable ? 'text-muted-foreground' : ''"
|
||||
>{{ item.label }}</span>
|
||||
:class="!displayItems[index].applicable ? 'text-muted-foreground' : ''"
|
||||
>{{ displayItems[index].label }}</span>
|
||||
<span
|
||||
v-if="!item.applicable"
|
||||
v-if="!displayItems[index].applicable"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
>
|
||||
(不适用)
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ item.desc }}
|
||||
{{ displayItems[index].desc }}
|
||||
</p>
|
||||
<p
|
||||
v-if="displayItems[index].evidenceHint"
|
||||
class="text-[11px] text-muted-foreground/80 mt-1"
|
||||
>
|
||||
依据: {{ displayItems[index].evidenceHint }}
|
||||
</p>
|
||||
|
||||
<!-- Mode sub-config -->
|
||||
<div
|
||||
v-if="item.modeOptions.length > 0 && item.enabled && item.applicable"
|
||||
v-if="displayItems[index].modeOptions.length > 0 && displayItems[index].enabled && displayItems[index].applicable"
|
||||
class="flex gap-0.5 mt-2 p-0.5 bg-muted/40 rounded-md w-fit"
|
||||
>
|
||||
<button
|
||||
v-for="modeOpt in item.modeOptions"
|
||||
v-for="modeOpt in displayItems[index].modeOptions"
|
||||
:key="modeOpt.value"
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
|
||||
:class="[
|
||||
item.mode === modeOpt.value
|
||||
displayItems[index].mode === modeOpt.value
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="setPresetMode(index, modeOpt.value)"
|
||||
@click="setPresetModeByPreset(displayItems[index].preset, modeOpt.value)"
|
||||
>
|
||||
{{ modeOpt.label }}
|
||||
</button>
|
||||
@@ -388,7 +415,12 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { updateProvider } from '@/api/endpoints'
|
||||
import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
|
||||
import type { PoolPresetMeta } from '@/api/endpoints/pool'
|
||||
import type { PoolAdvancedConfig, ClaudeCodeAdvancedConfig, SchedulingPresetItem } from '@/api/endpoints/types/provider'
|
||||
import type {
|
||||
PoolAdvancedConfig,
|
||||
ClaudeCodeAdvancedConfig,
|
||||
SchedulingPresetItem,
|
||||
ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints/types/provider'
|
||||
|
||||
interface PresetModeOption {
|
||||
value: string
|
||||
@@ -403,6 +435,8 @@ interface PresetListItem {
|
||||
mode: string | null
|
||||
modeOptions: PresetModeOption[]
|
||||
applicable: boolean
|
||||
mutexGroup: string | null
|
||||
evidenceHint: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -415,7 +449,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
saved: [provider: ProviderWithEndpointsSummary]
|
||||
}>()
|
||||
|
||||
const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
||||
@@ -423,6 +457,8 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
||||
name: 'lru',
|
||||
label: 'LRU 轮转',
|
||||
description: '最久未使用的 Key 优先',
|
||||
mutex_group: 'distribution_mode',
|
||||
evidence_hint: '依据 LRU 时间戳(最近未使用优先)',
|
||||
providers: [],
|
||||
modes: null,
|
||||
default_mode: null,
|
||||
@@ -431,6 +467,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
||||
name: 'free_team_first',
|
||||
label: 'Free/Team 优先',
|
||||
description: '优先消耗低档账号(依赖 plan_type)',
|
||||
evidence_hint: '依据 plan_type(oauth_plan_type 或 upstream_metadata)',
|
||||
providers: ['codex', 'kiro'],
|
||||
modes: [
|
||||
{ value: 'free_only', label: 'Free' },
|
||||
@@ -443,6 +480,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
||||
name: 'quota_balanced',
|
||||
label: '额度平均',
|
||||
description: '优先选额度消耗最少的账号',
|
||||
evidence_hint: '依据账号配额使用率;无配额时回退到窗口成本使用',
|
||||
providers: [],
|
||||
modes: null,
|
||||
default_mode: null,
|
||||
@@ -451,6 +489,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
||||
name: 'recent_refresh',
|
||||
label: '额度刷新优先',
|
||||
description: '优先选即将刷新额度的账号',
|
||||
evidence_hint: '依据账号额度重置倒计时(next_reset / reset_seconds)',
|
||||
providers: ['codex', 'kiro'],
|
||||
modes: null,
|
||||
default_mode: null,
|
||||
@@ -459,6 +498,44 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
||||
name: 'single_account',
|
||||
label: '单号优先',
|
||||
description: '集中使用同一账号(反向 LRU)',
|
||||
mutex_group: 'distribution_mode',
|
||||
evidence_hint: '先按账号优先级(internal_priority),同级再按反向 LRU 集中',
|
||||
providers: [],
|
||||
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,
|
||||
@@ -530,6 +607,11 @@ function normalizeMode(value: unknown): string | null {
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function normalizeMutexGroup(value: unknown): string | null {
|
||||
const normalized = String(value ?? '').trim().toLowerCase()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
|
||||
const ordered: PoolPresetMeta[] = []
|
||||
const seen = new Set<string>()
|
||||
@@ -556,6 +638,8 @@ function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
|
||||
providers,
|
||||
modes: modes && modes.length > 0 ? modes : null,
|
||||
default_mode: defaultMode,
|
||||
mutex_group: normalizeMutexGroup(raw.mutex_group),
|
||||
evidence_hint: String(raw.evidence_hint ?? '').trim() || null,
|
||||
})
|
||||
}
|
||||
return ordered
|
||||
@@ -622,6 +706,8 @@ function buildDefaultPresetList(): PresetListItem[] {
|
||||
mode: defaultModeForPreset(def),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
mutexGroup: normalizeMutexGroup(def.mutex_group),
|
||||
evidenceHint: String(def.evidence_hint ?? '').trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -675,6 +761,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
|
||||
mode: resolveMode(def, ci.mode),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
mutexGroup: normalizeMutexGroup(def.mutex_group),
|
||||
evidenceHint: String(def.evidence_hint ?? '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -688,6 +776,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
|
||||
mode: defaultModeForPreset(def),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
mutexGroup: normalizeMutexGroup(def.mutex_group),
|
||||
evidenceHint: String(def.evidence_hint ?? '').trim(),
|
||||
})
|
||||
}
|
||||
return ordered
|
||||
@@ -708,6 +798,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
|
||||
mode: null,
|
||||
modeOptions: [],
|
||||
applicable: isApplicablePreset(lruDef),
|
||||
mutexGroup: normalizeMutexGroup(lruDef.mutex_group),
|
||||
evidenceHint: String(lruDef.evidence_hint ?? '').trim(),
|
||||
})
|
||||
seen.add('lru')
|
||||
}
|
||||
@@ -725,6 +817,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
|
||||
mode: resolveMode(def, undefined),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
mutexGroup: normalizeMutexGroup(def.mutex_group),
|
||||
evidenceHint: String(def.evidence_hint ?? '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -738,19 +832,143 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
|
||||
mode: defaultModeForPreset(def),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
mutexGroup: normalizeMutexGroup(def.mutex_group),
|
||||
evidenceHint: String(def.evidence_hint ?? '').trim(),
|
||||
})
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
function togglePreset(index: number, enabled: boolean) {
|
||||
presetList.value[index].enabled = enabled
|
||||
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
|
||||
}
|
||||
|
||||
function setPresetMode(index: number, mode: string) {
|
||||
presetList.value[index].mode = mode
|
||||
function togglePreset(index: number, enabled: boolean) {
|
||||
const item = presetList.value[index]
|
||||
if (!item) return
|
||||
if (!item.mutexGroup) {
|
||||
item.enabled = enabled
|
||||
return
|
||||
}
|
||||
|
||||
const memberIndexes = getMutexGroupIndexes(index)
|
||||
if (enabled) {
|
||||
for (const memberIndex of memberIndexes) {
|
||||
const member = presetList.value[memberIndex]
|
||||
if (!member) continue
|
||||
member.enabled = member.preset === item.preset && member.applicable
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (const memberIndex of memberIndexes) {
|
||||
const member = presetList.value[memberIndex]
|
||||
if (!member) continue
|
||||
member.enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
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 mutexGroupIndexMap = computed<Record<string, number[]>>(() => {
|
||||
const grouped: Record<string, number[]> = {}
|
||||
presetList.value.forEach((item, index) => {
|
||||
if (!item.mutexGroup) return
|
||||
if (!grouped[item.mutexGroup]) grouped[item.mutexGroup] = []
|
||||
grouped[item.mutexGroup].push(index)
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
function getMutexGroupIndexes(index: number): number[] {
|
||||
const item = presetList.value[index]
|
||||
if (!item?.mutexGroup) return []
|
||||
return mutexGroupIndexMap.value[item.mutexGroup] || []
|
||||
}
|
||||
|
||||
function isMutexFollower(index: number): boolean {
|
||||
const memberIndexes = getMutexGroupIndexes(index)
|
||||
if (memberIndexes.length <= 1) return false
|
||||
return memberIndexes[0] !== index
|
||||
}
|
||||
|
||||
function getMutexGroupItems(index: number): PresetListItem[] {
|
||||
return getMutexGroupIndexes(index)
|
||||
.map(memberIndex => presetList.value[memberIndex])
|
||||
.filter((item): item is PresetListItem => Boolean(item))
|
||||
}
|
||||
|
||||
function getMutexSelectedItem(index: number): PresetListItem | null {
|
||||
const members = getMutexGroupItems(index)
|
||||
if (members.length === 0) return null
|
||||
return members.find(member => member.enabled && member.applicable)
|
||||
|| members.find(member => member.applicable)
|
||||
|| members[0]
|
||||
}
|
||||
|
||||
function selectMutexPreset(index: number, presetName: string) {
|
||||
const memberIndexes = getMutexGroupIndexes(index)
|
||||
if (memberIndexes.length === 0) return
|
||||
for (const memberIndex of memberIndexes) {
|
||||
const member = presetList.value[memberIndex]
|
||||
if (!member) continue
|
||||
member.enabled = member.preset === presetName && member.applicable
|
||||
}
|
||||
}
|
||||
|
||||
function canDragPreset(index: number): boolean {
|
||||
const item = presetList.value[index]
|
||||
if (!item) return false
|
||||
if (item.mutexGroup) return false
|
||||
return item.applicable
|
||||
}
|
||||
|
||||
const EMPTY_DISPLAY_ITEM: PresetListItem = {
|
||||
preset: '',
|
||||
label: '',
|
||||
desc: '',
|
||||
enabled: false,
|
||||
mode: null,
|
||||
modeOptions: [],
|
||||
applicable: false,
|
||||
mutexGroup: null,
|
||||
evidenceHint: '',
|
||||
}
|
||||
|
||||
const displayItems = computed<PresetListItem[]>(() =>
|
||||
presetList.value.map((item, index) => {
|
||||
if (!item) return EMPTY_DISPLAY_ITEM
|
||||
if (!item.mutexGroup) return item
|
||||
return getMutexSelectedItem(index) || item
|
||||
}),
|
||||
)
|
||||
|
||||
function handleDragStart(index: number, event: DragEvent) {
|
||||
draggedIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
@@ -790,7 +1008,7 @@ watch(() => props.modelValue, async (open) => {
|
||||
if (!open) return
|
||||
showAdvanced.value = false
|
||||
await ensurePresetDefsLoaded()
|
||||
presetList.value = loadFromConfig(props.currentConfig)
|
||||
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
|
||||
|
||||
const cfg = props.currentConfig
|
||||
form.value = {
|
||||
@@ -819,6 +1037,7 @@ watch(() => props.modelValue, async (open) => {
|
||||
async function handleSave() {
|
||||
loading.value = true
|
||||
try {
|
||||
presetList.value = normalizeMutexSelection(presetList.value)
|
||||
const schedulingPresets: SchedulingPresetItem[] = presetList.value.map(item => {
|
||||
const result: SchedulingPresetItem = {
|
||||
preset: item.preset,
|
||||
@@ -857,9 +1076,9 @@ async function handleSave() {
|
||||
cli_only_enabled: cf.cli_only_enabled,
|
||||
}
|
||||
}
|
||||
await updateProvider(props.providerId, payload)
|
||||
const updatedProvider = await updateProvider(props.providerId, payload)
|
||||
success('号池调度已保存')
|
||||
emit('saved')
|
||||
emit('saved', updatedProvider)
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
|
||||
@@ -189,16 +189,6 @@
|
||||
v-if="selectedProviderId"
|
||||
class="h-4 w-px bg-border"
|
||||
/>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="添加账号"
|
||||
@click="showImportDialog = true"
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
v-if="selectedProviderId"
|
||||
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
|
||||
@@ -209,6 +199,20 @@
|
||||
<span class="font-medium text-foreground/90">{{ poolSchedulingLabel }}</span>
|
||||
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
<div
|
||||
v-if="selectedProviderId"
|
||||
class="h-4 w-px bg-border"
|
||||
/>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="添加账号"
|
||||
@click="showImportDialog = true"
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
@@ -279,7 +283,7 @@
|
||||
v-if="keyPage.keys.length > 0"
|
||||
class="hidden xl:block overflow-x-auto"
|
||||
>
|
||||
<Table class="min-w-[1420px]">
|
||||
<Table class="min-w-[1400px]">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[320px] font-semibold whitespace-nowrap">
|
||||
@@ -312,14 +316,38 @@
|
||||
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||
:class="getRowClass(key)"
|
||||
>
|
||||
<TableCell class="py-3">
|
||||
<div class="max-w-[260px] min-w-0">
|
||||
<TableCell
|
||||
class="py-3"
|
||||
>
|
||||
<div class="max-w-[320px] min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-sm truncate block">
|
||||
{{ key.key_name || '未命名' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
|
||||
<input
|
||||
v-if="editingPriorityKeyId === key.key_id"
|
||||
:value="editingPriorityValue"
|
||||
type="number"
|
||||
min="1"
|
||||
max="999999"
|
||||
autofocus
|
||||
class="h-[18px] w-10 rounded border border-primary/50 bg-background px-1 text-[10px] tabular-nums text-foreground outline-none ring-1 ring-primary/30 shrink-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@input="(e) => editingPriorityValue = Number((e.target as HTMLInputElement).value || 0)"
|
||||
@blur="(e) => finishEditInternalPriority(key, e)"
|
||||
@keydown.enter.prevent="(e) => finishEditInternalPriority(key, e)"
|
||||
@keydown.esc.prevent="cancelEditInternalPriority"
|
||||
>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="h-4 px-1 rounded text-[10px] tabular-nums text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors shrink-0"
|
||||
title="点击编辑优先级"
|
||||
@click="startEditInternalPriority(key)"
|
||||
>
|
||||
P{{ key.internal_priority ?? 50 }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="key.auth_type === 'oauth'"
|
||||
variant="ghost"
|
||||
@@ -622,6 +650,14 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="h-4 px-1 rounded text-[10px] tabular-nums text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors shrink-0"
|
||||
title="点击修改优先级"
|
||||
@click="quickEditInternalPriority(key)"
|
||||
>
|
||||
P{{ key.internal_priority ?? 50 }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="key.auth_type === 'oauth'"
|
||||
variant="ghost"
|
||||
@@ -940,7 +976,7 @@
|
||||
:provider-type="selectedProviderType"
|
||||
:current-config="selectedProviderConfig"
|
||||
:current-claude-config="selectedProviderClaudeConfig"
|
||||
@saved="loadOverview"
|
||||
@saved="handleSchedulingSaved"
|
||||
/>
|
||||
<KeyFormDialog
|
||||
v-if="selectedProviderId"
|
||||
@@ -1080,7 +1116,8 @@ async function loadOverview() {
|
||||
|
||||
if (!selectedStillExists) {
|
||||
if (enabledProviders.length > 0) {
|
||||
await selectProvider(enabledProviders[0].provider_id)
|
||||
// Do not block overview loading on key list fetch; keys area has its own loader.
|
||||
void selectProvider(enabledProviders[0].provider_id)
|
||||
} else {
|
||||
selectedProviderId.value = null
|
||||
selectedProviderData.value = null
|
||||
@@ -1096,6 +1133,15 @@ async function loadOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
|
||||
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
|
||||
if (selectedProviderId.value && updatedProvider.id === selectedProviderId.value) {
|
||||
selectedProviderData.value = updatedProvider
|
||||
}
|
||||
showSchedulingDialog.value = false
|
||||
await loadOverview()
|
||||
}
|
||||
|
||||
// --- Provider Selection ---
|
||||
const selectedProviderId = ref<string | null>(null)
|
||||
const selectedProviderData = ref<ProviderWithEndpointsSummary | null>(null)
|
||||
@@ -1160,11 +1206,10 @@ const poolSchedulingLabel = computed(() => {
|
||||
// New format: object list with { preset, enabled }
|
||||
const first = presets[0]
|
||||
if (typeof first === 'object' && first !== null && 'preset' in first) {
|
||||
const enabledLabels = (presets as Array<{ preset: string; enabled?: boolean }>)
|
||||
const enabledCount = (presets as Array<{ preset: string; enabled?: boolean }>)
|
||||
.filter(p => p.enabled !== false)
|
||||
.map(p => presetLabels[normalizePresetName(p.preset)])
|
||||
.filter(Boolean)
|
||||
return enabledLabels.length > 0 ? enabledLabels.join('+') : '无启用维度'
|
||||
.length
|
||||
return enabledCount > 0 ? `${enabledCount} 维度` : '无启用维度'
|
||||
}
|
||||
|
||||
// Legacy string list format
|
||||
@@ -1172,7 +1217,7 @@ const poolSchedulingLabel = computed(() => {
|
||||
const labels = (presets as string[])
|
||||
.map(p => presetLabels[normalizePresetName(p)])
|
||||
.filter(Boolean)
|
||||
if (labels.length > 0) return labels.join('+')
|
||||
if (labels.length > 0) return `${labels.length} 维度`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1259,6 +1304,9 @@ const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
|
||||
const proxyMobilePopoverOpenKeyId = ref<string | null>(null)
|
||||
const deletingKeyId = ref<string | null>(null)
|
||||
const togglingKeyId = ref<string | null>(null)
|
||||
const editingPriorityKeyId = ref<string | null>(null)
|
||||
const editingPriorityValue = ref<number>(0)
|
||||
const prioritySavingKeyId = ref<string | null>(null)
|
||||
|
||||
const keyPermissionsDialogOpen = ref(false)
|
||||
const keyFormDialogOpen = ref(false)
|
||||
@@ -1510,6 +1558,66 @@ const editingKey = computed<EndpointAPIKey | null>(() => {
|
||||
return toEndpointApiKey(editingKeyDetail.value)
|
||||
})
|
||||
|
||||
function sortCurrentPageKeysByPriority() {
|
||||
keyPage.value.keys = [...keyPage.value.keys].sort((a, b) => {
|
||||
const pa = Number(a.internal_priority ?? 50)
|
||||
const pb = Number(b.internal_priority ?? 50)
|
||||
if (pa !== pb) return pa - pb
|
||||
return (a.created_at || '').localeCompare(b.created_at || '')
|
||||
})
|
||||
}
|
||||
|
||||
function startEditInternalPriority(key: PoolKeyDetail) {
|
||||
editingPriorityKeyId.value = key.key_id
|
||||
editingPriorityValue.value = Number(key.internal_priority ?? 50)
|
||||
}
|
||||
|
||||
function cancelEditInternalPriority() {
|
||||
editingPriorityKeyId.value = null
|
||||
editingPriorityValue.value = 0
|
||||
}
|
||||
|
||||
async function applyInternalPriority(key: PoolKeyDetail, nextPriority: number) {
|
||||
const normalized = Math.max(1, Math.min(999999, Math.floor(nextPriority)))
|
||||
if (Number(key.internal_priority ?? 50) === normalized) return
|
||||
|
||||
prioritySavingKeyId.value = key.key_id
|
||||
try {
|
||||
await updateProviderKey(key.key_id, { internal_priority: normalized })
|
||||
key.internal_priority = normalized
|
||||
sortCurrentPageKeysByPriority()
|
||||
success('账号优先级已更新')
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '更新优先级失败'))
|
||||
} finally {
|
||||
prioritySavingKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function quickEditInternalPriority(key: PoolKeyDetail) {
|
||||
const raw = window.prompt('设置账号优先级(1-999999,数字越小越优先)', String(key.internal_priority ?? 50))
|
||||
if (raw === null) return
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
showWarning('请输入有效数字')
|
||||
return
|
||||
}
|
||||
await applyInternalPriority(key, parsed)
|
||||
}
|
||||
|
||||
async function finishEditInternalPriority(
|
||||
key: PoolKeyDetail,
|
||||
event: FocusEvent | KeyboardEvent,
|
||||
) {
|
||||
if (prioritySavingKeyId.value) return
|
||||
const target = event.target as HTMLInputElement | null
|
||||
const raw = target?.value ?? String(editingPriorityValue.value)
|
||||
const parsed = Number(raw)
|
||||
const nextPriority = Number.isFinite(parsed) ? parsed : Number(key.internal_priority ?? 50)
|
||||
cancelEditInternalPriority()
|
||||
await applyInternalPriority(key, nextPriority)
|
||||
}
|
||||
|
||||
function handleEditKey(key: PoolKeyDetail) {
|
||||
editingKeyDetail.value = key
|
||||
if (key.auth_type === 'oauth') {
|
||||
@@ -1790,6 +1898,13 @@ const COOLDOWN_REASON_MAP: Record<string, string> = {
|
||||
auth_failed_401: '401 认证失败',
|
||||
payment_required_402: '402 欠费',
|
||||
server_error_500: '500 错误',
|
||||
request_timeout_408: '408 超时',
|
||||
conflict_409: '409 冲突',
|
||||
locked_423: '423 锁定',
|
||||
too_early_425: '425 Too Early',
|
||||
bad_gateway_502: '502 网关错误',
|
||||
service_unavailable_503: '503 服务不可用',
|
||||
gateway_timeout_504: '504 网关超时',
|
||||
}
|
||||
|
||||
function formatCooldownReason(reason: string): string {
|
||||
@@ -2150,9 +2265,10 @@ function formatRelativeTime(isoStr: string): string {
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
startCountdownTimer()
|
||||
await Promise.all([loadSchedulingPresetMetas(), loadOverview()])
|
||||
void loadSchedulingPresetMetas()
|
||||
void loadOverview()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
Reference in New Issue
Block a user