mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
Merge PR #672: 支持账号批量配置与可用模型管理
This commit is contained in:
@@ -534,6 +534,7 @@ type QuickSelectorValue =
|
||||
| 'enabled'
|
||||
|
||||
type BatchActionValue =
|
||||
| 'edit_config'
|
||||
| 'export'
|
||||
| 'delete'
|
||||
| 'refresh_oauth'
|
||||
@@ -574,6 +575,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
changed: []
|
||||
'edit-config': [keyIds: string[]]
|
||||
}>()
|
||||
|
||||
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
|
||||
@@ -590,6 +592,7 @@ const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }>
|
||||
]
|
||||
|
||||
const ACTION_OPTIONS: BatchActionOption[] = [
|
||||
{ value: 'edit_config', label: '编辑配置', hint: '统一修改支持 API、调度参数与自动获取模型设置。' },
|
||||
{ value: 'refresh_quota', label: '刷新额度', hint: '调用额度刷新接口,适合核对最新配额状态。' },
|
||||
{ value: 'refresh_oauth', label: '刷新 OAuth', hint: '仅对 OAuth 账号有效,非 OAuth 账号会自动跳过。' },
|
||||
{ value: 'set_proxy', label: '配置代理', hint: '为选中账号绑定独立代理节点。' },
|
||||
@@ -979,6 +982,11 @@ async function confirmAndExecuteAction(action: BatchActionValue): Promise<void>
|
||||
}
|
||||
if (!canExecuteSpecifiedAction(action)) return
|
||||
|
||||
if (action === 'edit_config') {
|
||||
await openBatchEditor()
|
||||
return
|
||||
}
|
||||
|
||||
const actionOption = ACTION_OPTIONS.find((item) => item.value === action)
|
||||
const actionLabel = actionOption?.label || '执行动作'
|
||||
const scopeLabel = selectAllFiltered.value ? '筛选结果' : '已选账号'
|
||||
@@ -992,6 +1000,31 @@ async function confirmAndExecuteAction(action: BatchActionValue): Promise<void>
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
async function openBatchEditor(): Promise<void> {
|
||||
if (executing.value || selectedCount.value === 0) return
|
||||
executing.value = true
|
||||
progressDone.value = 0
|
||||
progressTotal.value = 0
|
||||
progressLabel.value = selectAllFiltered.value ? '正在解析筛选结果...' : '正在准备批量编辑...'
|
||||
try {
|
||||
const selectedKeys = await resolveSelectedItems()
|
||||
const keyIds = selectedKeys.map(key => key.key_id)
|
||||
if (keyIds.length === 0) {
|
||||
warning('未找到可编辑账号,请刷新列表重试')
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', false)
|
||||
emit('edit-config', keyIds)
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '准备批量编辑失败'))
|
||||
} finally {
|
||||
executing.value = false
|
||||
progressDone.value = 0
|
||||
progressTotal.value = 0
|
||||
progressLabel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const DELETE_POLL_INTERVAL_MS = 2000
|
||||
const DELETE_POLL_MAX_MS = 10 * 60 * 1000
|
||||
const DELETE_POLL_MAX_FAILURES = 3
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
title="批量编辑密钥"
|
||||
:description="dialogDescription"
|
||||
:icon="SquarePen"
|
||||
size="3xl"
|
||||
persistent
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<Tabs v-model="activeTab">
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="configuration">
|
||||
密钥配置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
可用模型范围
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="configuration"
|
||||
class="max-h-[min(64vh,40rem)] overflow-y-auto pr-1"
|
||||
>
|
||||
<div class="divide-y divide-border/70">
|
||||
<section class="space-y-3 py-4 first:pt-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<Checkbox v-model="form.applyApiFormats" />
|
||||
<span>支持的 API</span>
|
||||
</label>
|
||||
<div
|
||||
class="grid gap-2 sm:grid-cols-2"
|
||||
:class="!form.applyApiFormats ? 'pointer-events-none opacity-45' : ''"
|
||||
>
|
||||
<label
|
||||
v-for="format in visibleApiFormats"
|
||||
:key="format"
|
||||
class="flex min-h-9 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-muted/40"
|
||||
:class="form.apiFormats.includes(format) ? 'border-primary/50 bg-primary/5' : 'border-border/70'"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="form.apiFormats.includes(format)"
|
||||
:disabled="!form.applyApiFormats"
|
||||
@update:checked="checked => toggleApiFormat(format, checked)"
|
||||
/>
|
||||
<span class="truncate">{{ formatApiFormat(format) }}</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="visibleApiFormats.length === 0"
|
||||
class="text-xs text-muted-foreground sm:col-span-2"
|
||||
>
|
||||
当前提供商没有可配置的 API 格式
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 py-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyActive"
|
||||
label="启用状态"
|
||||
>
|
||||
<Select
|
||||
v-model="activeValue"
|
||||
:disabled="!form.applyActive"
|
||||
>
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">
|
||||
启用
|
||||
</SelectItem>
|
||||
<SelectItem value="disabled">
|
||||
禁用
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyInternalPriority"
|
||||
label="优先级"
|
||||
>
|
||||
<Input
|
||||
v-model="form.internalPriority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-9"
|
||||
:disabled="!form.applyInternalPriority"
|
||||
/>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyRpmLimit"
|
||||
label="RPM 限制"
|
||||
>
|
||||
<Input
|
||||
v-model="form.rpmLimit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="自适应"
|
||||
class="h-9"
|
||||
:disabled="!form.applyRpmLimit"
|
||||
/>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyConcurrentLimit"
|
||||
label="并发请求上限"
|
||||
>
|
||||
<Input
|
||||
v-model="form.concurrentLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="不限制"
|
||||
class="h-9"
|
||||
:disabled="!form.applyConcurrentLimit"
|
||||
/>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyCacheTtl"
|
||||
label="缓存 TTL"
|
||||
>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="form.cacheTtlMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
max="60"
|
||||
class="h-9 pr-12"
|
||||
:disabled="!form.applyCacheTtl"
|
||||
/>
|
||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">分钟</span>
|
||||
</div>
|
||||
</BatchFieldToggle>
|
||||
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyProbeInterval"
|
||||
label="熔断探测"
|
||||
>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="form.maxProbeIntervalMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
max="32"
|
||||
class="h-9 pr-12"
|
||||
:disabled="!form.applyProbeInterval"
|
||||
/>
|
||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">分钟</span>
|
||||
</div>
|
||||
</BatchFieldToggle>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 py-4">
|
||||
<BatchFieldToggle
|
||||
v-model="form.applyAutoFetchModels"
|
||||
label="应用自动获取设置"
|
||||
>
|
||||
<div class="space-y-3 rounded-md border border-border/60 bg-muted/30 px-3 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label class="text-sm font-medium">自动获取上游可用模型</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
定时更新上游模型,配合模型映射使用
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.autoFetchModels"
|
||||
:disabled="!form.applyAutoFetchModels"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.autoFetchModels"
|
||||
class="space-y-2 border-t border-border/40 pt-3"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">包含规则</Label>
|
||||
<Input
|
||||
v-model="form.includePatterns"
|
||||
placeholder="gpt-*, claude-*, 留空包含全部"
|
||||
class="h-9"
|
||||
:disabled="!form.applyAutoFetchModels"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">排除规则</Label>
|
||||
<Input
|
||||
v-model="form.excludePatterns"
|
||||
placeholder="*-preview, *-beta"
|
||||
class="h-9"
|
||||
:disabled="!form.applyAutoFetchModels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
逗号分隔,支持 * ? 通配符,不区分大小写
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BatchFieldToggle>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 py-4">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<Checkbox v-model="form.applyNote" />
|
||||
<span>备注</span>
|
||||
</label>
|
||||
<Textarea
|
||||
v-model="form.note"
|
||||
rows="3"
|
||||
placeholder="留空可清除备注"
|
||||
:disabled="!form.applyNote"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="models"
|
||||
class="max-h-[min(64vh,40rem)] overflow-y-auto pr-1"
|
||||
>
|
||||
<div class="space-y-4 py-1">
|
||||
<div class="space-y-1 border-b border-border/70 pb-4">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<Checkbox v-model="form.applyAllowedModels" />
|
||||
<span>应用可用模型范围</span>
|
||||
</label>
|
||||
<p class="pl-6 text-xs text-muted-foreground">
|
||||
限制所选账号能够承接的模型;不限制时允许全部模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="space-y-4"
|
||||
:class="!form.applyAllowedModels ? 'pointer-events-none opacity-45' : ''"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 border-b border-border/70 pb-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium">
|
||||
允许全部模型
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
关闭后仅允许下方选中的模型
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.unrestrictedModels"
|
||||
:disabled="!form.applyAllowedModels"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="space-y-3"
|
||||
:class="modelSelectionDisabled ? 'pointer-events-none opacity-45' : ''"
|
||||
>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="modelSearch"
|
||||
placeholder="搜索或输入自定义模型"
|
||||
class="h-9 pl-8"
|
||||
:disabled="modelSelectionDisabled"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-9 shrink-0"
|
||||
:disabled="fetchingUpstreamModels || modelSelectionDisabled"
|
||||
@click="fetchUpstreamModels(true)"
|
||||
>
|
||||
<RefreshCw
|
||||
class="mr-2 h-4 w-4"
|
||||
:class="fetchingUpstreamModels ? 'animate-spin' : ''"
|
||||
/>
|
||||
获取上游模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>已选择 {{ form.selectedModels.length }} 个模型</span>
|
||||
<button
|
||||
v-if="filteredModels.length > 0"
|
||||
type="button"
|
||||
class="text-primary hover:underline"
|
||||
:disabled="modelSelectionDisabled"
|
||||
@click="toggleFilteredModels"
|
||||
>
|
||||
{{ areFilteredModelsSelected ? '取消当前结果' : '选择当前结果' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-md border border-border/70">
|
||||
<button
|
||||
v-if="canAddCustomModel"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 border-b border-dashed px-3 py-2 text-left text-sm hover:bg-muted/40"
|
||||
:disabled="modelSelectionDisabled"
|
||||
@click="addCustomModel"
|
||||
>
|
||||
<Plus class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ normalizedModelSearch }}</span>
|
||||
<span class="text-xs text-muted-foreground">添加</span>
|
||||
</button>
|
||||
<div class="max-h-72 overflow-y-auto">
|
||||
<label
|
||||
v-for="model in filteredModels"
|
||||
:key="model.id"
|
||||
class="flex cursor-pointer items-center gap-2 border-b border-border/60 px-3 py-2 last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="form.selectedModels.includes(model.id)"
|
||||
:disabled="modelSelectionDisabled"
|
||||
@update:checked="checked => toggleModel(model.id, checked)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-sm">{{ model.id }}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="h-5 shrink-0 px-1.5 text-[10px]"
|
||||
>
|
||||
{{ model.source }}
|
||||
</Badge>
|
||||
</label>
|
||||
<div
|
||||
v-if="loadingModels"
|
||||
class="flex items-center justify-center py-10 text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredModels.length === 0"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无匹配模型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium">
|
||||
锁定已选模型
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
自动获取开启时,锁定的模型不会被同步移除
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.lockSelectedModels"
|
||||
:disabled="modelSelectionDisabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
@click="closeDialog"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="saving"
|
||||
@click="saveChanges"
|
||||
>
|
||||
<Loader2
|
||||
v-if="saving"
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
{{ saving ? '保存中...' : `应用到 ${keyIds.length} 个密钥` }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { Loader2, Plus, RefreshCw, Search, SquarePen } from 'lucide-vue-next'
|
||||
import { getProviderModels } from '@/api/endpoints/models'
|
||||
import { batchUpdatePoolKeys } from '@/api/endpoints/pool'
|
||||
import { formatApiFormat, sortApiFormats, type UpstreamModel } from '@/api/endpoints/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useUpstreamModelsCache } from '@/features/providers/composables/useUpstreamModelsCache'
|
||||
import BatchFieldToggle from './PoolKeyBatchFieldToggle.vue'
|
||||
import {
|
||||
buildPoolKeyBatchUpdatePatch,
|
||||
type PoolKeyBatchEditState,
|
||||
} from '../utils/poolKeyBatchEdit'
|
||||
|
||||
interface ModelOption {
|
||||
id: string
|
||||
source: '提供商' | '上游' | '自定义'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
providerName?: string
|
||||
keyIds: string[]
|
||||
availableApiFormats: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, warning, error: showError } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
const { fetchModelsForKeys } = useUpstreamModelsCache()
|
||||
|
||||
const activeTab = ref('configuration')
|
||||
const saving = ref(false)
|
||||
const loadingProviderModels = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const providerModelIds = ref<string[]>([])
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
const modelSearch = ref('')
|
||||
|
||||
function createInitialForm(): PoolKeyBatchEditState {
|
||||
return {
|
||||
applyApiFormats: false,
|
||||
apiFormats: [],
|
||||
applyActive: false,
|
||||
isActive: true,
|
||||
applyInternalPriority: false,
|
||||
internalPriority: '0',
|
||||
applyRpmLimit: false,
|
||||
rpmLimit: '',
|
||||
applyConcurrentLimit: false,
|
||||
concurrentLimit: '',
|
||||
applyCacheTtl: false,
|
||||
cacheTtlMinutes: '5',
|
||||
applyProbeInterval: false,
|
||||
maxProbeIntervalMinutes: '32',
|
||||
applyNote: false,
|
||||
note: '',
|
||||
applyAutoFetchModels: false,
|
||||
autoFetchModels: false,
|
||||
includePatterns: '',
|
||||
excludePatterns: '',
|
||||
applyAllowedModels: false,
|
||||
unrestrictedModels: true,
|
||||
selectedModels: [],
|
||||
lockSelectedModels: true,
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive<PoolKeyBatchEditState>(createInitialForm())
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
const providerName = props.providerName?.trim()
|
||||
const prefix = providerName ? `${providerName} · ` : ''
|
||||
return `${prefix}已选 ${props.keyIds.length} 个密钥`
|
||||
})
|
||||
const visibleApiFormats = computed(() => sortApiFormats(props.availableApiFormats || []))
|
||||
const loadingModels = computed(() => loadingProviderModels.value || fetchingUpstreamModels.value)
|
||||
const normalizedModelSearch = computed(() => modelSearch.value.trim())
|
||||
const modelSelectionDisabled = computed(() => (
|
||||
!form.applyAllowedModels || form.unrestrictedModels
|
||||
))
|
||||
const activeValue = computed({
|
||||
get: () => form.isActive ? 'enabled' : 'disabled',
|
||||
set: value => { form.isActive = value === 'enabled' },
|
||||
})
|
||||
|
||||
const allModels = computed<ModelOption[]>(() => {
|
||||
const byId = new Map<string, ModelOption>()
|
||||
for (const id of providerModelIds.value) {
|
||||
const normalized = id.trim()
|
||||
if (normalized) byId.set(normalized, { id: normalized, source: '提供商' })
|
||||
}
|
||||
for (const model of upstreamModels.value) {
|
||||
const normalized = model.id?.trim()
|
||||
if (normalized && !byId.has(normalized)) {
|
||||
byId.set(normalized, { id: normalized, source: '上游' })
|
||||
}
|
||||
}
|
||||
for (const id of form.selectedModels) {
|
||||
const normalized = id.trim()
|
||||
if (normalized && !byId.has(normalized)) {
|
||||
byId.set(normalized, { id: normalized, source: '自定义' })
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id))
|
||||
})
|
||||
|
||||
const filteredModels = computed(() => {
|
||||
const search = normalizedModelSearch.value.toLowerCase()
|
||||
if (!search) return allModels.value
|
||||
return allModels.value.filter(model => model.id.toLowerCase().includes(search))
|
||||
})
|
||||
const canAddCustomModel = computed(() => {
|
||||
const model = normalizedModelSearch.value
|
||||
return Boolean(model) && !allModels.value.some(item => item.id === model)
|
||||
})
|
||||
const areFilteredModelsSelected = computed(() => (
|
||||
filteredModels.value.length > 0
|
||||
&& filteredModels.value.every(model => form.selectedModels.includes(model.id))
|
||||
))
|
||||
|
||||
function resetDialog(): void {
|
||||
Object.assign(form, createInitialForm())
|
||||
activeTab.value = 'configuration'
|
||||
modelSearch.value = ''
|
||||
providerModelIds.value = []
|
||||
upstreamModels.value = []
|
||||
}
|
||||
|
||||
function toggleApiFormat(format: string, checked: boolean): void {
|
||||
const next = new Set(form.apiFormats)
|
||||
if (checked) next.add(format)
|
||||
else next.delete(format)
|
||||
form.apiFormats = [...next]
|
||||
}
|
||||
|
||||
function toggleModel(modelId: string, checked: boolean): void {
|
||||
const next = new Set(form.selectedModels)
|
||||
if (checked) next.add(modelId)
|
||||
else next.delete(modelId)
|
||||
form.selectedModels = [...next]
|
||||
}
|
||||
|
||||
function toggleFilteredModels(): void {
|
||||
const next = new Set(form.selectedModels)
|
||||
const select = !areFilteredModelsSelected.value
|
||||
for (const model of filteredModels.value) {
|
||||
if (select) next.add(model.id)
|
||||
else next.delete(model.id)
|
||||
}
|
||||
form.selectedModels = [...next]
|
||||
}
|
||||
|
||||
function addCustomModel(): void {
|
||||
const model = normalizedModelSearch.value
|
||||
if (!model) return
|
||||
toggleModel(model, true)
|
||||
modelSearch.value = ''
|
||||
}
|
||||
|
||||
async function loadProviderModels(): Promise<void> {
|
||||
if (!props.providerId) return
|
||||
loadingProviderModels.value = true
|
||||
try {
|
||||
const models = await getProviderModels(props.providerId, { limit: 1000 })
|
||||
providerModelIds.value = models
|
||||
.map(model => model.provider_model_name?.trim())
|
||||
.filter((model): model is string => Boolean(model))
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '加载提供商模型失败'))
|
||||
} finally {
|
||||
loadingProviderModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUpstreamModels(forceRefresh = false): Promise<void> {
|
||||
if (!props.providerId || props.keyIds.length === 0) return
|
||||
fetchingUpstreamModels.value = true
|
||||
try {
|
||||
const result = await fetchModelsForKeys(props.providerId, props.keyIds, forceRefresh)
|
||||
if (result.error) {
|
||||
warning(result.error)
|
||||
return
|
||||
}
|
||||
upstreamModels.value = result.models
|
||||
if (result.warning) warning(result.warning)
|
||||
else success(`已获取 ${result.models.length} 个上游模型`)
|
||||
} finally {
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogUpdate(value: boolean): void {
|
||||
if (!value && !saving.value) closeDialog()
|
||||
}
|
||||
|
||||
function closeDialog(): void {
|
||||
if (saving.value) return
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function saveChanges(): Promise<void> {
|
||||
if (saving.value) return
|
||||
if (props.keyIds.length === 0) {
|
||||
warning('请选择要编辑的密钥')
|
||||
return
|
||||
}
|
||||
const build = buildPoolKeyBatchUpdatePatch(form)
|
||||
if (!build.patch || build.error) {
|
||||
warning(build.error || '批量配置无效')
|
||||
if (form.applyAllowedModels && build.error?.includes('模型')) activeTab.value = 'models'
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: '应用批量配置',
|
||||
message: `将对 ${props.keyIds.length} 个密钥修改:${build.fieldLabels.join('、')}。是否继续?`,
|
||||
confirmText: '确认应用',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await batchUpdatePoolKeys(props.providerId, {
|
||||
key_ids: props.keyIds,
|
||||
patch: build.patch,
|
||||
})
|
||||
const modelSync = result.model_sync
|
||||
if (modelSync?.failed) {
|
||||
warning(`已更新 ${result.affected} 个密钥,${modelSync.failed} 个账号的模型同步失败`)
|
||||
} else if (modelSync && modelSync.attempted < modelSync.requested) {
|
||||
warning(`已更新 ${result.affected} 个密钥,部分账号未执行即时模型同步`)
|
||||
} else {
|
||||
success(result.message)
|
||||
}
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '批量更新密钥失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
open => {
|
||||
if (!open) return
|
||||
resetDialog()
|
||||
void loadProviderModels()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="min-w-0 space-y-1.5">
|
||||
<label class="flex items-center gap-2 text-xs font-medium">
|
||||
<Checkbox
|
||||
:model-value="modelValue"
|
||||
@update:model-value="value => emit('update:modelValue', value)"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</label>
|
||||
<div :class="!modelValue ? 'pointer-events-none opacity-45' : ''">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Checkbox } from '@/components/ui'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
@@ -394,7 +394,7 @@ const desktopPostProxyActions = computed<HeaderAction[]>(() => {
|
||||
}
|
||||
actions.push(
|
||||
{ key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 },
|
||||
{ key: 'accountBatch', title: legacyT('账号'), event: 'accountBatch', icon: Users },
|
||||
{ key: 'accountBatch', title: legacyT('账号批量操作'), event: 'accountBatch', icon: Users },
|
||||
{ key: 'toggleProvider', title: props.providerToggleButtonTitle, event: 'toggleProvider', icon: Power },
|
||||
)
|
||||
return actions
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PoolKeyBatchEditDialog from '../PoolKeyBatchEditDialog.vue'
|
||||
|
||||
vi.mock('@/api/endpoints/models', () => ({
|
||||
getProviderModels: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/endpoints/pool', () => ({
|
||||
batchUpdatePoolKeys: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/providers/composables/useUpstreamModelsCache', () => ({
|
||||
useUpstreamModelsCache: () => ({
|
||||
fetchModelsForKeys: vi.fn().mockResolvedValue({ models: [] }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('PoolKeyBatchEditDialog', () => {
|
||||
it('uses the same automatic model discovery language as the single-key editor', async () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(PoolKeyBatchEditDialog, {
|
||||
open: true,
|
||||
providerId: 'provider-1',
|
||||
providerName: 'Google API',
|
||||
keyIds: ['key-1', 'key-2'],
|
||||
availableApiFormats: ['gemini:generate_content'],
|
||||
})
|
||||
|
||||
app.mount(root)
|
||||
await nextTick()
|
||||
|
||||
const applyLabel = [...document.body.querySelectorAll('label')]
|
||||
.find(label => label.textContent?.includes('应用自动获取设置'))
|
||||
const applyCheckbox = applyLabel?.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(applyCheckbox).toBeTruthy()
|
||||
if (applyCheckbox) {
|
||||
applyCheckbox.checked = true
|
||||
applyCheckbox.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
document.body.querySelector<HTMLButtonElement>('[role="switch"]')?.click()
|
||||
await nextTick()
|
||||
|
||||
const text = document.body.textContent || ''
|
||||
expect(text).toContain('自动获取上游可用模型')
|
||||
expect(text).toContain('包含规则')
|
||||
expect(text).toContain('排除规则')
|
||||
expect(text).toContain('可用模型范围')
|
||||
expect(text).not.toContain('模型权限')
|
||||
expect(text).not.toContain('自动发现')
|
||||
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@ describe('PoolManagementHeader', () => {
|
||||
refreshTitle: '刷新',
|
||||
onImport: () => events.push('import'),
|
||||
onScheduling: () => events.push('scheduling'),
|
||||
onAccountBatch: () => events.push('accountBatch'),
|
||||
onDemandMetrics: () => events.push('demandMetrics'),
|
||||
onRefresh: () => events.push('refresh'),
|
||||
})
|
||||
@@ -53,10 +54,11 @@ describe('PoolManagementHeader', () => {
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[title="添加账号"]')?.click()
|
||||
root.querySelector<HTMLButtonElement>('[title="点击调整号池调度"]')?.click()
|
||||
root.querySelector<HTMLButtonElement>('[title="账号批量操作"]')?.click()
|
||||
root.querySelector<HTMLButtonElement>('[title="查看自适应热池指标"]')?.click()
|
||||
root.querySelector<HTMLButtonElement>('[title="刷新"]')?.click()
|
||||
|
||||
expect(events).toEqual(['import', 'scheduling', 'demandMetrics', 'refresh'])
|
||||
expect(events).toEqual(['import', 'scheduling', 'accountBatch', 'demandMetrics', 'refresh'])
|
||||
expect(root.textContent).toContain('2 维度')
|
||||
|
||||
app.unmount()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildPoolKeyBatchUpdatePatch,
|
||||
parsePoolKeyModelPatterns,
|
||||
type PoolKeyBatchEditState,
|
||||
} from '../poolKeyBatchEdit'
|
||||
|
||||
function state(overrides: Partial<PoolKeyBatchEditState> = {}): PoolKeyBatchEditState {
|
||||
return {
|
||||
applyApiFormats: false,
|
||||
apiFormats: [],
|
||||
applyActive: false,
|
||||
isActive: true,
|
||||
applyInternalPriority: false,
|
||||
internalPriority: '0',
|
||||
applyRpmLimit: false,
|
||||
rpmLimit: '',
|
||||
applyConcurrentLimit: false,
|
||||
concurrentLimit: '',
|
||||
applyCacheTtl: false,
|
||||
cacheTtlMinutes: '5',
|
||||
applyProbeInterval: false,
|
||||
maxProbeIntervalMinutes: '32',
|
||||
applyNote: false,
|
||||
note: '',
|
||||
applyAutoFetchModels: false,
|
||||
autoFetchModels: false,
|
||||
includePatterns: '',
|
||||
excludePatterns: '',
|
||||
applyAllowedModels: false,
|
||||
unrestrictedModels: true,
|
||||
selectedModels: [],
|
||||
lockSelectedModels: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildPoolKeyBatchUpdatePatch', () => {
|
||||
it('only emits fields explicitly enabled by the operator', () => {
|
||||
const result = buildPoolKeyBatchUpdatePatch(state({
|
||||
applyApiFormats: true,
|
||||
apiFormats: ['openai:responses', 'openai:responses', ' openai:chat '],
|
||||
applyRpmLimit: true,
|
||||
rpmLimit: '',
|
||||
}))
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.patch).toEqual({
|
||||
api_formats: ['openai:responses', 'openai:chat'],
|
||||
rpm_limit: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds an explicit model access range without changing automatic discovery', () => {
|
||||
const result = buildPoolKeyBatchUpdatePatch(state({
|
||||
applyAllowedModels: true,
|
||||
unrestrictedModels: false,
|
||||
selectedModels: ['gpt-5.6-sol', 'gpt-5.6-sol', 'gpt-5.6-luna'],
|
||||
lockSelectedModels: false,
|
||||
}))
|
||||
|
||||
expect(result.patch).toEqual({
|
||||
allowed_models: ['gpt-5.6-sol', 'gpt-5.6-luna'],
|
||||
locked_models: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('builds automatic discovery filters independently from the model access range', () => {
|
||||
const result = buildPoolKeyBatchUpdatePatch(state({
|
||||
applyAutoFetchModels: true,
|
||||
autoFetchModels: true,
|
||||
includePatterns: 'gpt-*,\nclaude-*',
|
||||
excludePatterns: '*-preview, *-beta',
|
||||
}))
|
||||
|
||||
expect(result.patch).toEqual({
|
||||
auto_fetch_models: true,
|
||||
model_include_patterns: ['gpt-*', 'claude-*'],
|
||||
model_exclude_patterns: ['*-preview', '*-beta'],
|
||||
})
|
||||
})
|
||||
|
||||
it('disables automatic discovery without rewriting model filters or access limits', () => {
|
||||
const result = buildPoolKeyBatchUpdatePatch(state({
|
||||
applyAutoFetchModels: true,
|
||||
autoFetchModels: false,
|
||||
includePatterns: 'gpt-*',
|
||||
excludePatterns: '*-preview',
|
||||
}))
|
||||
|
||||
expect(result.patch).toEqual({
|
||||
auto_fetch_models: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('locks selected models only when the operator enables locking', () => {
|
||||
const result = buildPoolKeyBatchUpdatePatch(state({
|
||||
applyAllowedModels: true,
|
||||
unrestrictedModels: false,
|
||||
selectedModels: ['gpt-5.6-sol'],
|
||||
lockSelectedModels: true,
|
||||
}))
|
||||
|
||||
expect(result.patch).toEqual({
|
||||
allowed_models: ['gpt-5.6-sol'],
|
||||
locked_models: ['gpt-5.6-sol'],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty fields and invalid ranges before the request is sent', () => {
|
||||
expect(buildPoolKeyBatchUpdatePatch(state()).error).toBe('请至少启用一个批量编辑字段')
|
||||
expect(buildPoolKeyBatchUpdatePatch(state({
|
||||
applyApiFormats: true,
|
||||
})).error).toBe('请至少选择一个支持的 API')
|
||||
expect(buildPoolKeyBatchUpdatePatch(state({
|
||||
applyCacheTtl: true,
|
||||
cacheTtlMinutes: '61',
|
||||
})).error).toBe('缓存 TTL 必须是 0-60 的整数')
|
||||
expect(buildPoolKeyBatchUpdatePatch(state({
|
||||
applyAllowedModels: true,
|
||||
unrestrictedModels: false,
|
||||
})).error).toBe('请至少选择一个可用模型')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePoolKeyModelPatterns', () => {
|
||||
it('normalizes comma and line separated patterns', () => {
|
||||
expect(parsePoolKeyModelPatterns(' gpt-* , claude-*\ngpt-* ')).toEqual([
|
||||
'gpt-*',
|
||||
'claude-*',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { PoolKeyBatchUpdatePatch } from '@/api/endpoints/pool'
|
||||
|
||||
export interface PoolKeyBatchEditState {
|
||||
applyApiFormats: boolean
|
||||
apiFormats: string[]
|
||||
applyActive: boolean
|
||||
isActive: boolean
|
||||
applyInternalPriority: boolean
|
||||
internalPriority: string
|
||||
applyRpmLimit: boolean
|
||||
rpmLimit: string
|
||||
applyConcurrentLimit: boolean
|
||||
concurrentLimit: string
|
||||
applyCacheTtl: boolean
|
||||
cacheTtlMinutes: string
|
||||
applyProbeInterval: boolean
|
||||
maxProbeIntervalMinutes: string
|
||||
applyNote: boolean
|
||||
note: string
|
||||
applyAutoFetchModels: boolean
|
||||
autoFetchModels: boolean
|
||||
includePatterns: string
|
||||
excludePatterns: string
|
||||
applyAllowedModels: boolean
|
||||
unrestrictedModels: boolean
|
||||
selectedModels: string[]
|
||||
lockSelectedModels: boolean
|
||||
}
|
||||
|
||||
export interface PoolKeyBatchPatchBuildResult {
|
||||
patch: PoolKeyBatchUpdatePatch | null
|
||||
fieldLabels: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function uniqueTrimmed(values: string[]): string[] {
|
||||
return [...new Set(values.map(value => value.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
export function parsePoolKeyModelPatterns(value: string): string[] {
|
||||
return uniqueTrimmed(value.split(/[,\n]/))
|
||||
}
|
||||
|
||||
function parseIntegerField(
|
||||
value: string,
|
||||
label: string,
|
||||
min: number,
|
||||
max?: number,
|
||||
nullable = false,
|
||||
): { value?: number | null; error?: string } {
|
||||
const normalized = value.trim()
|
||||
if (!normalized) {
|
||||
return nullable ? { value: null } : { error: `${label} 不能为空` }
|
||||
}
|
||||
const parsed = Number(normalized)
|
||||
if (!Number.isInteger(parsed) || parsed < min || (max !== undefined && parsed > max)) {
|
||||
const range = max === undefined ? `不小于 ${min}` : `${min}-${max}`
|
||||
return { error: `${label} 必须是 ${range} 的整数` }
|
||||
}
|
||||
return { value: parsed }
|
||||
}
|
||||
|
||||
export function buildPoolKeyBatchUpdatePatch(
|
||||
state: PoolKeyBatchEditState,
|
||||
): PoolKeyBatchPatchBuildResult {
|
||||
const patch: PoolKeyBatchUpdatePatch = {}
|
||||
const fieldLabels: string[] = []
|
||||
|
||||
if (state.applyApiFormats) {
|
||||
const apiFormats = uniqueTrimmed(state.apiFormats)
|
||||
if (apiFormats.length === 0) {
|
||||
return { patch: null, fieldLabels, error: '请至少选择一个支持的 API' }
|
||||
}
|
||||
patch.api_formats = apiFormats
|
||||
fieldLabels.push('支持 API')
|
||||
}
|
||||
|
||||
if (state.applyActive) {
|
||||
patch.is_active = state.isActive
|
||||
fieldLabels.push('启用状态')
|
||||
}
|
||||
|
||||
if (state.applyInternalPriority) {
|
||||
const parsed = parseIntegerField(state.internalPriority, '优先级', 0)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.internal_priority = parsed.value as number
|
||||
fieldLabels.push('优先级')
|
||||
}
|
||||
|
||||
if (state.applyRpmLimit) {
|
||||
const parsed = parseIntegerField(state.rpmLimit, 'RPM 限制', 1, 10000, true)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.rpm_limit = parsed.value
|
||||
fieldLabels.push('RPM 限制')
|
||||
}
|
||||
|
||||
if (state.applyConcurrentLimit) {
|
||||
const parsed = parseIntegerField(state.concurrentLimit, '并发请求上限', 0, undefined, true)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.concurrent_limit = parsed.value
|
||||
fieldLabels.push('并发请求上限')
|
||||
}
|
||||
|
||||
if (state.applyCacheTtl) {
|
||||
const parsed = parseIntegerField(state.cacheTtlMinutes, '缓存 TTL', 0, 60)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.cache_ttl_minutes = parsed.value as number
|
||||
fieldLabels.push('缓存 TTL')
|
||||
}
|
||||
|
||||
if (state.applyProbeInterval) {
|
||||
const parsed = parseIntegerField(state.maxProbeIntervalMinutes, '熔断探测', 0, 32)
|
||||
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
|
||||
patch.max_probe_interval_minutes = parsed.value as number
|
||||
fieldLabels.push('熔断探测')
|
||||
}
|
||||
|
||||
if (state.applyNote) {
|
||||
patch.note = state.note.trim() || null
|
||||
fieldLabels.push('备注')
|
||||
}
|
||||
|
||||
if (state.applyAutoFetchModels) {
|
||||
patch.auto_fetch_models = state.autoFetchModels
|
||||
if (state.autoFetchModels) {
|
||||
patch.model_include_patterns = parsePoolKeyModelPatterns(state.includePatterns)
|
||||
patch.model_exclude_patterns = parsePoolKeyModelPatterns(state.excludePatterns)
|
||||
}
|
||||
fieldLabels.push('自动获取上游可用模型')
|
||||
}
|
||||
|
||||
if (state.applyAllowedModels) {
|
||||
const selectedModels = uniqueTrimmed(state.selectedModels)
|
||||
if (!state.unrestrictedModels && selectedModels.length === 0) {
|
||||
return { patch: null, fieldLabels, error: '请至少选择一个可用模型' }
|
||||
}
|
||||
patch.allowed_models = state.unrestrictedModels ? null : selectedModels
|
||||
patch.locked_models = state.unrestrictedModels || !state.lockSelectedModels
|
||||
? []
|
||||
: selectedModels
|
||||
fieldLabels.push('可用模型范围')
|
||||
}
|
||||
|
||||
if (fieldLabels.length === 0) {
|
||||
return { patch: null, fieldLabels, error: '请至少启用一个批量编辑字段' }
|
||||
}
|
||||
|
||||
return { patch, fieldLabels, error: null }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const adminApiMocks = vi.hoisted(() => ({
|
||||
queryProviderModels: vi.fn(),
|
||||
queryProviderModelsForKeys: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({ adminApi: adminApiMocks }))
|
||||
|
||||
import { useUpstreamModelsCache } from '../useUpstreamModelsCache'
|
||||
|
||||
function response(modelId: string) {
|
||||
return {
|
||||
success: true,
|
||||
data: { models: [{ id: modelId }] },
|
||||
provider: { id: 'provider-1', name: 'Provider', display_name: 'Provider' },
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useUpstreamModelsCache', () => {
|
||||
beforeEach(() => {
|
||||
adminApiMocks.queryProviderModels.mockReset()
|
||||
adminApiMocks.queryProviderModelsForKeys.mockReset()
|
||||
})
|
||||
|
||||
it('deduplicates equivalent multi-key model requests', async () => {
|
||||
const request = deferred<ReturnType<typeof response>>()
|
||||
adminApiMocks.queryProviderModelsForKeys.mockReturnValue(request.promise)
|
||||
const { fetchModelsForKeys } = useUpstreamModelsCache()
|
||||
|
||||
const first = fetchModelsForKeys('provider-1', ['key-b', 'key-a', 'key-a'])
|
||||
const second = fetchModelsForKeys('provider-1', ['key-a', 'key-b'])
|
||||
expect(adminApiMocks.queryProviderModelsForKeys).toHaveBeenCalledTimes(1)
|
||||
expect(adminApiMocks.queryProviderModelsForKeys).toHaveBeenCalledWith(
|
||||
'provider-1',
|
||||
['key-a', 'key-b'],
|
||||
false,
|
||||
)
|
||||
|
||||
request.resolve(response('gpt-5.6-sol'))
|
||||
await expect(first).resolves.toMatchObject({ models: [{ id: 'gpt-5.6-sol' }] })
|
||||
await expect(second).resolves.toMatchObject({ models: [{ id: 'gpt-5.6-sol' }] })
|
||||
})
|
||||
|
||||
it('keeps the loading state owned by the latest forced request', async () => {
|
||||
const firstRequest = deferred<ReturnType<typeof response>>()
|
||||
const forcedRequest = deferred<ReturnType<typeof response>>()
|
||||
adminApiMocks.queryProviderModels
|
||||
.mockReturnValueOnce(firstRequest.promise)
|
||||
.mockReturnValueOnce(forcedRequest.promise)
|
||||
const { fetchModels, isLoading } = useUpstreamModelsCache()
|
||||
|
||||
const first = fetchModels('provider-1', 'key-a')
|
||||
const forced = fetchModels('provider-1', 'key-a', true)
|
||||
expect(isLoading('provider-1', 'key-a')).toBe(true)
|
||||
|
||||
firstRequest.resolve(response('gpt-5.6-sol'))
|
||||
await first
|
||||
expect(isLoading('provider-1', 'key-a')).toBe(true)
|
||||
|
||||
forcedRequest.resolve(response('gpt-5.6-luna'))
|
||||
await forced
|
||||
expect(isLoading('provider-1', 'key-a')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { adminApi, type ProviderModelsQueryResponse } from '@/api/admin'
|
||||
import { parseUpstreamModelError } from '@/utils/errorParser'
|
||||
import type { UpstreamModel } from '@/api/endpoints/types'
|
||||
|
||||
@@ -15,6 +15,8 @@ type FetchResult = { models: UpstreamModel[]; error?: string; warning?: string;
|
||||
|
||||
// 进行中的请求(用于去重并发请求)
|
||||
const pendingRequests = new Map<string, Promise<FetchResult>>()
|
||||
const activeRequestIds = new Map<string, number>()
|
||||
let nextRequestId = 0
|
||||
|
||||
// 请求状态
|
||||
const loadingMap = ref<Map<string, boolean>>(new Map())
|
||||
@@ -26,6 +28,57 @@ function getRequestKey(providerId: string, apiKeyId?: string): string {
|
||||
return apiKeyId ? `${providerId}:${apiKeyId}` : providerId
|
||||
}
|
||||
|
||||
function getBatchRequestKey(providerId: string, apiKeyIds: string[]): string {
|
||||
return `${providerId}:batch:${JSON.stringify([...new Set(apiKeyIds)].sort())}`
|
||||
}
|
||||
|
||||
function providerModelsFetchResult(response: ProviderModelsQueryResponse): FetchResult {
|
||||
if (response.success && response.data?.models) {
|
||||
const partialWarning = response.data.warning ?? response.data.error
|
||||
return {
|
||||
models: response.data.models,
|
||||
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
|
||||
fromCache: response.data.from_cache,
|
||||
}
|
||||
}
|
||||
const rawError = response.data?.error || response.data?.warning || '获取上游模型失败'
|
||||
return { models: [], error: parseUpstreamModelError(rawError) }
|
||||
}
|
||||
|
||||
function fetchProviderModels(
|
||||
requestKey: string,
|
||||
forceRefresh: boolean,
|
||||
request: () => Promise<ProviderModelsQueryResponse>,
|
||||
): Promise<FetchResult> {
|
||||
if (!forceRefresh && pendingRequests.has(requestKey)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return pendingRequests.get(requestKey)!
|
||||
}
|
||||
|
||||
const requestId = ++nextRequestId
|
||||
activeRequestIds.set(requestKey, requestId)
|
||||
loadingMap.value.set(requestKey, true)
|
||||
const requestPromise = (async (): Promise<FetchResult> => {
|
||||
try {
|
||||
return providerModelsFetchResult(await request())
|
||||
} catch (err: unknown) {
|
||||
const rawError = isAxiosError(err)
|
||||
? (err.response?.data?.detail ?? err.message)
|
||||
: (err instanceof Error ? err.message : String(err))
|
||||
return { models: [], error: parseUpstreamModelError(rawError || '获取上游模型失败') }
|
||||
} finally {
|
||||
if (activeRequestIds.get(requestKey) === requestId) {
|
||||
loadingMap.value.set(requestKey, false)
|
||||
pendingRequests.delete(requestKey)
|
||||
activeRequestIds.delete(requestKey)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(requestKey, requestPromise)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
export function useUpstreamModelsCache() {
|
||||
/**
|
||||
* 获取上游模型列表
|
||||
@@ -40,41 +93,32 @@ export function useUpstreamModelsCache() {
|
||||
forceRefresh = false
|
||||
): Promise<FetchResult> {
|
||||
const requestKey = getRequestKey(providerId, apiKeyId)
|
||||
return fetchProviderModels(
|
||||
requestKey,
|
||||
forceRefresh,
|
||||
() => adminApi.queryProviderModels(providerId, apiKeyId, forceRefresh),
|
||||
)
|
||||
}
|
||||
|
||||
// 强制刷新时不复用进行中的请求
|
||||
if (!forceRefresh && pendingRequests.has(requestKey)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return pendingRequests.get(requestKey)!
|
||||
async function fetchModelsForKeys(
|
||||
providerId: string,
|
||||
apiKeyIds: string[],
|
||||
forceRefresh = false
|
||||
): Promise<FetchResult> {
|
||||
const normalizedKeyIds = [...new Set(apiKeyIds.map(id => id.trim()).filter(Boolean))].sort()
|
||||
if (normalizedKeyIds.length === 0) {
|
||||
return { models: [], error: '请先选择账号' }
|
||||
}
|
||||
|
||||
// 创建新请求
|
||||
const requestPromise = (async (): Promise<FetchResult> => {
|
||||
try {
|
||||
loadingMap.value.set(requestKey, true)
|
||||
const response = await adminApi.queryProviderModels(providerId, apiKeyId, forceRefresh)
|
||||
|
||||
if (response.success && response.data?.models) {
|
||||
const partialWarning = response.data.warning ?? response.data.error
|
||||
return {
|
||||
models: response.data.models,
|
||||
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
|
||||
fromCache: response.data.from_cache
|
||||
}
|
||||
} else {
|
||||
const rawError = response.data?.error || response.data?.warning || '获取上游模型失败'
|
||||
return { models: [], error: parseUpstreamModelError(rawError) }
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const rawError = isAxiosError(err) ? (err.response?.data?.detail ?? err.message) : (err instanceof Error ? err.message : String(err))
|
||||
return { models: [], error: parseUpstreamModelError(rawError || '获取上游模型失败') }
|
||||
} finally {
|
||||
loadingMap.value.set(requestKey, false)
|
||||
pendingRequests.delete(requestKey)
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(requestKey, requestPromise)
|
||||
return requestPromise
|
||||
const requestKey = getBatchRequestKey(providerId, normalizedKeyIds)
|
||||
return fetchProviderModels(
|
||||
requestKey,
|
||||
forceRefresh,
|
||||
() => adminApi.queryProviderModelsForKeys(
|
||||
providerId,
|
||||
normalizedKeyIds,
|
||||
forceRefresh,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +131,7 @@ export function useUpstreamModelsCache() {
|
||||
|
||||
return {
|
||||
fetchModels,
|
||||
fetchModelsForKeys,
|
||||
isLoading,
|
||||
loadingMap
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user