feat(keys): 新增密钥自动获取模型功能

- 添加 auto_fetch_models 字段支持定时从上游 API 获取可用模型
- 添加 locked_models 字段支持锁定模型,刷新时不会被删除
- 新增 ModelFetchScheduler 调度器定期执行模型获取任务
- 前端 KeyFormDialog 添加自动获取模型开关
- 前端 KeyAllowedModelsEditDialog 支持模型锁定操作
- ProviderDetailDrawer 显示密钥同步状态
- 数据库迁移添加新字段
This commit is contained in:
fawney19
2026-01-14 13:12:19 +08:00
parent 3b194d3c23
commit a8813cfb89
14 changed files with 923 additions and 184 deletions

View File

@@ -2,7 +2,7 @@
<Dialog
:model-value="isOpen"
:title="props.apiKey?.name ? `模型权限 - ${props.apiKey.name}` : '模型权限'"
description="选中的模型将被允许访问,不选择则允许全部"
:description="isAutoFetchMode ? '自动获取模式:只允许已选择的模型,锁定的模型刷新时不会被删除' : '选中的模型将被允许访问,不选择则允许全部'"
:icon="Shield"
size="2xl"
@update:model-value="handleDialogUpdate"
@@ -34,42 +34,25 @@
</div>
<!-- 已选数量徽章 -->
<span
v-if="selectedModels.length === 0"
v-if="selectedModels.length === 0 && !isAutoFetchMode"
class="h-6 px-2 text-xs rounded flex items-center bg-muted text-muted-foreground shrink-0"
>
全部模型
</span>
<span
v-else-if="selectedModels.length === 0 && isAutoFetchMode"
class="h-6 px-2 text-xs rounded flex items-center bg-amber-500/10 text-amber-600 dark:text-amber-400 shrink-0"
>
未选择模型
</span>
<span
v-else
class="h-6 px-2 text-xs rounded flex items-center bg-primary/10 text-primary shrink-0"
>
已选 {{ selectedModels.length }}
</span>
<button
v-if="upstreamModelsLoaded"
type="button"
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
title="刷新上游模型"
:disabled="fetchingUpstreamModels"
@click="fetchUpstreamModels()"
>
<RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': fetchingUpstreamModels }"
/>
</button>
<Button
v-else-if="!fetchingUpstreamModels"
variant="outline"
size="sm"
class="h-8"
title="从提供<E68F90><E4BE9B><EFBFBD>获取模型"
@click="fetchUpstreamModels()"
>
<Zap class="w-4 h-4" />
</Button>
<Loader2
v-else
v-if="fetchingUpstreamModels"
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
/>
</div>
@@ -85,10 +68,10 @@
</div>
<template v-else>
<!-- 添加自定义模型搜索内容不在列表中时显示固定在顶部 -->
<!-- 添加自定义模型搜索内容不在列表中时显示 -->
<div
v-if="searchQuery && canAddAsCustom"
class="px-3 py-2 border-b bg-background sticky top-0 z-10"
class="px-3 py-2 border-b bg-background sticky top-0 z-30"
>
<div
class="flex items-center justify-between px-3 py-2 rounded-lg border border-dashed hover:border-primary hover:bg-primary/5 cursor-pointer transition-colors"
@@ -102,10 +85,10 @@
</div>
</div>
<!-- 自定义模型手动添加的始终显示全部搜索命中的排前面 -->
<!-- 自定义模型 -->
<div v-if="customModels.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
class="flex items-center justify-between px-3 h-9 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
@click="toggleGroupCollapse('custom')"
>
<div class="flex items-center gap-2">
@@ -136,15 +119,26 @@
class="w-3 h-3 text-primary-foreground"
/>
</div>
<span class="text-sm font-mono truncate">{{ model }}</span>
<span class="text-sm font-mono truncate flex-1">{{ model }}</span>
<button
v-if="selectedModels.includes(model)"
type="button"
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
@click="toggleLock(model, $event)"
>
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
<LockOpen v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<!-- 全局模型 -->
<div v-if="filteredGlobalModels.length > 0">
<!-- 提供商模型 -->
<template v-if="filteredGlobalModels.length > 0">
<!-- 标题 sticky top -->
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
class="flex items-center justify-between px-3 h-9 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
@click="toggleGroupCollapse('global')"
>
<div class="flex items-center gap-2">
@@ -152,7 +146,7 @@
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">全局模型</span>
<span class="text-xs font-medium">提供商模型</span>
<span class="text-xs text-muted-foreground">({{ filteredGlobalModels.length }})</span>
</div>
<button
@@ -163,6 +157,7 @@
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<!-- 内容 -->
<div
v-show="!collapsedGroups.has('global')"
class="space-y-1 p-2"
@@ -190,76 +185,91 @@
{{ model.name }}
</p>
</div>
<button
v-if="selectedModels.includes(model.name)"
type="button"
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
:title="isLocked(model.name) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
@click="toggleLock(model.name, $event)"
>
<Lock v-if="isLocked(model.name)" class="w-3.5 h-3.5" />
<LockOpen v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</template>
<!-- 上游模型 -->
<div
v-for="group in filteredUpstreamGroups"
:key="group.api_format"
>
<!-- 上游模型 -->
<template v-if="filteredUpstreamModels.length > 0">
<!-- 标题 sticky双向粘性top bottom -->
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
@click="toggleGroupCollapse(group.api_format)"
class="flex items-center justify-between px-3 h-9 bg-muted sticky z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
:style="{
top: filteredGlobalModels.length > 0 ? '36px' : '0px',
bottom: '0px'
}"
@click="toggleGroupCollapse('upstream')"
>
<div class="flex items-center gap-2">
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
</span>
<span class="text-xs text-muted-foreground">({{ group.models.length }})</span>
<span class="text-xs font-medium">上游模型</span>
<span class="text-xs text-muted-foreground">({{ upstreamModelNames.length }})</span>
</div>
<button
type="button"
class="text-xs text-primary hover:underline"
@click.stop="toggleAllUpstreamGroup(group.api_format)"
@click.stop="toggleAllUpstreamModels"
>
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消全选' : '全选' }}
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<!-- 内容 -->
<div
v-show="!collapsedGroups.has(group.api_format)"
v-show="!collapsedGroups.has('upstream')"
class="space-y-1 p-2"
>
<div
v-for="model in group.models"
:key="model.id"
v-for="model in filteredUpstreamModels"
:key="model"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleModel(model.id)"
@click="toggleModel(model)"
>
<div
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedModels.includes(model.id) ? 'bg-primary border-primary' : ''"
:class="selectedModels.includes(model) ? 'bg-primary border-primary' : ''"
>
<Check
v-if="selectedModels.includes(model.id)"
v-if="selectedModels.includes(model)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<span class="text-sm font-mono truncate">{{ model.id }}</span>
<span class="text-sm font-mono truncate flex-1">{{ model }}</span>
<button
v-if="selectedModels.includes(model)"
type="button"
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
@click="toggleLock(model, $event)"
>
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
<LockOpen v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</template>
<!-- 空状态 -->
<div
v-if="filteredGlobalModels.length === 0 && filteredUpstreamGroups.length === 0 && customModels.length === 0"
v-if="showEmptyState"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Shield class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}
</p>
<p
v-if="!upstreamModelsLoaded"
class="text-xs mt-1"
>
点击闪电按钮从上游获取模型
</p>
</div>
</template>
</div>
@@ -296,25 +306,24 @@ import { ref, computed, watch, onUnmounted } from 'vue'
import {
Shield,
Search,
RefreshCw,
Loader2,
Zap,
Plus,
Check,
ChevronDown
ChevronDown,
Lock,
LockOpen
} from 'lucide-vue-next'
import { Dialog, Button, Input } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError, parseUpstreamModelError } from '@/utils/errorParser'
import { parseApiError } from '@/utils/errorParser'
import {
updateProviderKey,
API_FORMAT_LABELS,
type EndpointAPIKey,
type AllowedModels,
} from '@/api/endpoints'
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import { adminApi } from '@/api/admin'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
import type { UpstreamModel } from '@/api/endpoints/types'
interface AvailableModel {
@@ -335,6 +344,7 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const { confirmWarning } = useConfirm()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
const isOpen = computed(() => props.open)
const saving = ref(false)
@@ -350,31 +360,52 @@ const searchQuery = ref('')
// 可用模型列表(全局模型)
const allGlobalModels = ref<AvailableModel[]>([])
// 上游模型列表
// 上游模型列表(从 API 查询获取)
const upstreamModels = ref<UpstreamModel[]>([])
// 已选中的模型
const selectedModels = ref<string[]>([])
const initialSelectedModels = ref<string[]>([])
// 已锁定的模型
const lockedModels = ref<string[]>([])
const initialLockedModels = ref<string[]>([])
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
const allCustomModels = ref<string[]>([])
// 是否为字典模式(按 API 格式区分)
const isDictMode = ref(false)
// 是否为自动获取模式
const isAutoFetchMode = computed(() => props.apiKey?.auto_fetch_models ?? false)
// 空状态判断
const showEmptyState = computed(() => {
return filteredGlobalModels.value.length === 0 &&
filteredUpstreamModels.value.length === 0 &&
customModels.value.length === 0
})
// 折叠状态
const collapsedGroups = ref<Set<string>>(new Set())
// 是否有更改
const hasChanges = computed(() => {
// 检查选中模型是否有变化
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
const sorted1 = [...selectedModels.value].sort()
const sorted2 = [...initialSelectedModels.value].sort()
return sorted1.some((v, i) => v !== sorted2[i])
if (sorted1.some((v, i) => v !== sorted2[i])) return true
// 检查锁定模型是否有变化
if (lockedModels.value.length !== initialLockedModels.value.length) return true
const sortedLocked1 = [...lockedModels.value].sort()
const sortedLocked2 = [...initialLockedModels.value].sort()
return sortedLocked1.some((v, i) => v !== sortedLocked2[i])
})
// 所有已知模型的集合(全局 + 上游)
// 所有已知模型的集合(全局 + 上游模型
const allKnownModels = computed(() => {
const set = new Set<string>()
allGlobalModels.value.forEach(m => set.add(m.name))
@@ -382,6 +413,58 @@ const allKnownModels = computed(() => {
return set
})
// 全局模型名称集合(用于判断模型是否为全局模型)
const globalModelNamesSet = computed(() => {
return new Set(allGlobalModels.value.map(m => m.name))
})
// 判断模型是否为全局模型(提供商模型)
function isGlobalModel(modelId: string): boolean {
return globalModelNamesSet.value.has(modelId)
}
// Key 支持的 API 格式
const keyApiFormats = computed(() => props.apiKey?.api_formats ?? [])
// 上游模型名称列表(去重后)
const upstreamModelNames = computed(() => {
const names = new Set<string>()
upstreamModels.value.forEach(m => {
// 只包含 Key 支持的 API 格式的模型
if (!m.api_format || keyApiFormats.value.includes(m.api_format)) {
names.add(m.id)
}
})
return Array.from(names).sort()
})
// 过滤后的上游模型
const filteredUpstreamModels = computed(() => {
if (!searchQuery.value.trim()) return upstreamModelNames.value
const query = searchQuery.value.toLowerCase()
return upstreamModelNames.value.filter(m => m.toLowerCase().includes(query))
})
// 上游模型是否全选
const isAllUpstreamModelsSelected = computed(() => {
if (filteredUpstreamModels.value.length === 0) return false
return filteredUpstreamModels.value.every(m => selectedModels.value.includes(m))
})
// 全选/取消全选上游模型
function toggleAllUpstreamModels() {
const allIds = filteredUpstreamModels.value
if (isAllUpstreamModelsSelected.value) {
selectedModels.value = selectedModels.value.filter(id => !allIds.includes(id))
} else {
allIds.forEach(id => {
if (!selectedModels.value.includes(id)) {
selectedModels.value.push(id)
}
})
}
}
// 自定义模型列表(显示所有添加过的,不因取消勾选而消失)
const customModels = computed(() => {
return allCustomModels.value
@@ -415,7 +498,7 @@ const canAddAsCustom = computed(() => {
// 精确匹配全局模型就不显示
if (allGlobalModels.value.some(m => m.name === search)) return false
// 精确匹配上游模型就不显示
if (upstreamModels.value.some(m => m.id === search)) return false
if (upstreamModelNames.value.includes(search)) return false
return true
})
@@ -429,59 +512,53 @@ const filteredGlobalModels = computed(() => {
)
})
// 按 API 格式分组的上游模型(过滤后)
const filteredUpstreamGroups = computed(() => {
if (!upstreamModelsLoaded.value) return []
const query = searchQuery.value.toLowerCase().trim()
const groups: Record<string, UpstreamModel[]> = {}
for (const model of upstreamModels.value) {
// 搜索过滤
if (query && !model.id.toLowerCase().includes(query)) continue
const format = model.api_format || 'unknown'
if (!groups[format]) groups[format] = []
groups[format].push(model)
}
const order = Object.keys(API_FORMAT_LABELS)
return Object.entries(groups)
.map(([api_format, models]) => ({ api_format, models }))
.filter(g => g.models.length > 0)
.sort((a, b) => {
const aIndex = order.indexOf(a.api_format)
const bIndex = order.indexOf(b.api_format)
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
if (aIndex === -1) return 1
if (bIndex === -1) return -1
return aIndex - bIndex
})
})
// 全局模型是否全选
const isAllGlobalModelsSelected = computed(() => {
if (filteredGlobalModels.value.length === 0) return false
return filteredGlobalModels.value.every(m => selectedModels.value.includes(m.name))
})
// 检查某个上游组是否全选
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
if (!group || group.models.length === 0) return false
return group.models.every(m => selectedModels.value.includes(m.id))
}
// 切换模型选中状态
function toggleModel(modelId: string) {
const idx = selectedModels.value.indexOf(modelId)
if (idx === -1) {
selectedModels.value.push(modelId)
// 自动获取模式下,勾选全局模型时自动锁定
// 防止下次刷新时被覆盖(即使全局模型与上游模型同名)
if (isAutoFetchMode.value && isGlobalModel(modelId)) {
if (!lockedModels.value.includes(modelId)) {
lockedModels.value.push(modelId)
}
}
} else {
selectedModels.value.splice(idx, 1)
// 取消选中时也取消锁定
const lockIdx = lockedModels.value.indexOf(modelId)
if (lockIdx !== -1) {
lockedModels.value.splice(lockIdx, 1)
}
}
}
// 切换模型锁定状态
function toggleLock(modelId: string, event: Event) {
event.stopPropagation()
// 只有已选中的模型才能锁定
if (!selectedModels.value.includes(modelId)) return
const idx = lockedModels.value.indexOf(modelId)
if (idx === -1) {
lockedModels.value.push(modelId)
} else {
lockedModels.value.splice(idx, 1)
}
}
// 检查模型是否被锁定
function isLocked(modelId: string): boolean {
return lockedModels.value.includes(modelId)
}
// 添加自定义模型
function addCustomModel() {
const model = searchQuery.value.trim()
@@ -501,30 +578,17 @@ function toggleAllGlobalModels() {
if (isAllGlobalModelsSelected.value) {
// 取消全选
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
// 同时取消锁定
lockedModels.value = lockedModels.value.filter(id => !allNames.includes(id))
} else {
// 全选
allNames.forEach(name => {
if (!selectedModels.value.includes(name)) {
selectedModels.value.push(name)
}
})
}
}
// 全选/取消全选某个上游组
function toggleAllUpstreamGroup(apiFormat: string) {
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
if (!group) return
const allIds = group.models.map(m => m.id)
if (isUpstreamGroupAllSelected(apiFormat)) {
// 取消全选
selectedModels.value = selectedModels.value.filter(id => !allIds.includes(id))
} else {
// 全选
allIds.forEach(id => {
if (!selectedModels.value.includes(id)) {
selectedModels.value.push(id)
// 自动获取模式下,勾选全局模型时自动锁定
if (isAutoFetchMode.value && !lockedModels.value.includes(name)) {
lockedModels.value.push(name)
}
}
})
}
@@ -558,29 +622,22 @@ async function loadGlobalModels() {
}
}
// 从提供商获取模型
// 从提供商获取模型(使用缓存)
async function fetchUpstreamModels() {
if (!props.providerId || !props.apiKey) return
try {
fetchingUpstreamModels.value = true
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
const result = await fetchCachedModels(props.providerId, props.apiKey.id)
if (loadingCancelled) return
if (response.success && response.data?.models) {
upstreamModels.value = response.data.models
if (result.models.length > 0) {
upstreamModels.value = result.models
upstreamModelsLoaded.value = true
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
const upstreamIds = new Set(response.data.models.map((m: UpstreamModel) => m.id))
const upstreamIds = new Set(result.models.map((m: UpstreamModel) => m.id))
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
} else {
const errorMsg = response.data?.error
? parseUpstreamModelError(response.data.error)
: '获取上游模型失败'
showError(errorMsg, '获取上游模型失败')
} else if (result.error) {
showError(result.error, '获取上游模型失败')
}
} catch (err: any) {
if (loadingCancelled) return
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
showError(parseUpstreamModelError(rawError), '获取上游模型失败')
} finally {
fetchingUpstreamModels.value = false
}
@@ -613,16 +670,45 @@ watch(() => props.open, async (open) => {
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
selectedModels.value = [...parsed]
initialSelectedModels.value = [...parsed]
// 加载锁定的模型
const locked = props.apiKey.locked_models ?? []
lockedModels.value = [...locked]
initialLockedModels.value = [...locked]
searchQuery.value = ''
upstreamModels.value = []
upstreamModelsLoaded.value = false
allCustomModels.value = []
// 默认全部收缩,自动获取模式下展开上游模型
if (props.apiKey.auto_fetch_models) {
collapsedGroups.value = new Set(['global', 'custom'])
} else {
collapsedGroups.value = new Set(['global', 'upstream', 'custom'])
}
// 加载全局模型
await loadGlobalModels()
// 加载全局模型后,从已选中的模型中提取自定义模型(不在全局模型中的)
const globalModelNames = new Set(allGlobalModels.value.map(m => m.name))
allCustomModels.value = selectedModels.value.filter(m => !globalModelNames.has(m))
// 自动获取上游模型
await fetchUpstreamModels()
// 自动获取模式下,用最新上游模型刷新(保留锁定的模型)
if (props.apiKey.auto_fetch_models) {
// 锁定的模型 + 最新上游模型(去重)
const newSelected = new Set(lockedModels.value)
upstreamModelNames.value.forEach(m => newSelected.add(m))
selectedModels.value = Array.from(newSelected)
initialSelectedModels.value = [...selectedModels.value]
}
// 提取自定义模型(不在全局模型和上游模型中的)
const upstreamModelIdsSet = new Set(upstreamModels.value.map(m => m.id))
// 自定义模型是用户手动添加的、不在已知模型列表中的
allCustomModels.value = selectedModels.value.filter(m =>
!globalModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m)
)
} else {
loadingCancelled = true
}
@@ -658,7 +744,13 @@ async function handleSave() {
? [...selectedModels.value]
: null
await updateProviderKey(props.apiKey.id, { allowed_models: newAllowed })
// 只保存已选中且被锁定的模型
const newLocked = lockedModels.value.filter(m => selectedModels.value.includes(m))
await updateProviderKey(props.apiKey.id, {
allowed_models: newAllowed,
locked_models: newLocked
})
success('模型权限已更新', '成功')
emit('saved')
emit('close')

View File

@@ -8,7 +8,7 @@
@update:model-value="handleDialogUpdate"
>
<form
class="space-y-4"
class="space-y-3"
autocomplete="off"
@submit.prevent="handleSave"
>
@@ -219,6 +219,23 @@
</button>
</div>
</div>
<!-- 自动获取模型 -->
<div class="flex items-center justify-between py-2 px-3 rounded-md border border-border/60 bg-muted/30">
<div class="space-y-0.5">
<Label class="text-sm font-medium">自动获取模型</Label>
<p class="text-xs text-muted-foreground">
系统将定时从上游获取可用模型, 但无法默认接受提供商模型
</p>
<p
v-if="showAutoFetchWarning"
class="text-xs text-amber-600 dark:text-amber-400"
>
已配置的模型权限将在下次获取时被覆盖
</p>
</div>
<Switch v-model="form.auto_fetch_models" />
</div>
</form>
<template #footer>
@@ -240,7 +257,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Dialog, Button, Input, Label } from '@/components/ui'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
import { Key, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
@@ -277,6 +294,21 @@ const { success, error: showError } = useToast()
// 排序后的可用 API 格式列表
const sortedApiFormats = computed(() => sortApiFormats(props.availableApiFormats))
// 显示自动获取模型警告:编辑模式下,原本未启用但现在启用,且已有 allowed_models
const showAutoFetchWarning = computed(() => {
if (!props.editingKey) return false
// 原本已启用,不需要警告
if (props.editingKey.auto_fetch_models) return false
// 现在未启用,不需要警告
if (!form.value.auto_fetch_models) return false
// 检查是否有已配置的模型权限
const allowedModels = props.editingKey.allowed_models
if (!allowedModels) return false
if (Array.isArray(allowedModels) && allowedModels.length === 0) return false
if (typeof allowedModels === 'object' && Object.keys(allowedModels).length === 0) return false
return true
})
const isOpen = computed(() => props.open)
const saving = ref(false)
const formNonce = ref(createFieldNonce())
@@ -303,7 +335,8 @@ const form = ref({
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {} as Record<string, boolean>
capabilities: {} as Record<string, boolean>,
auto_fetch_models: false
})
// 加载能力列表
@@ -404,7 +437,8 @@ function resetForm() {
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {}
capabilities: {},
auto_fetch_models: false
}
}
@@ -435,7 +469,8 @@ function loadKeyData() {
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
is_active: props.editingKey.is_active,
capabilities: { ...(props.editingKey.capabilities || {}) }
capabilities: { ...(props.editingKey.capabilities || {}) },
auto_fetch_models: props.editingKey.auto_fetch_models ?? false
}
}
@@ -514,7 +549,8 @@ async function handleSave() {
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
is_active: form.value.is_active,
capabilities: capabilitiesData
capabilities: capabilitiesData,
auto_fetch_models: form.value.auto_fetch_models
}
if (form.value.api_key.trim()) {
@@ -535,7 +571,8 @@ async function handleSave() {
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
capabilities: capabilitiesData || undefined
capabilities: capabilitiesData || undefined,
auto_fetch_models: form.value.auto_fetch_models
})
success('密钥已添加', '成功')
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加

View File

@@ -309,6 +309,17 @@
@keydown="(e) => handlePriorityKeydown(e, key)"
@blur="handlePriorityBlur(key)"
>
<!-- 自动获取模型状态 -->
<template v-if="key.auto_fetch_models">
<span class="text-muted-foreground/40">|</span>
<span
class="cursor-help"
:class="key.last_models_fetch_error ? 'text-amber-600 dark:text-amber-400' : ''"
:title="getAutoFetchStatusTitle(key)"
>
{{ key.last_models_fetch_error ? '同步失败' : '自动同步' }}
</span>
</template>
<!-- RPM 限制信息第二位 -->
<template v-if="key.rpm_limit || key.is_adaptive">
<span class="text-muted-foreground/40">|</span>
@@ -1596,6 +1607,22 @@ function getHealthScoreBarColor(score: number): string {
return 'bg-red-500 dark:bg-red-400'
}
// 获取自动获取模型状态的 title 提示
function getAutoFetchStatusTitle(key: EndpointAPIKey): string {
const parts: string[] = ['自动获取模型已启用']
if (key.last_models_fetch_at) {
const date = new Date(key.last_models_fetch_at)
parts.push(`上次同步: ${date.toLocaleString()}`)
}
if (key.last_models_fetch_error) {
parts.push(`错误: ${key.last_models_fetch_error}`)
}
return parts.join('\n')
}
// 检查指定格式是否熔断
function isFormatCircuitOpen(key: EndpointAPIKey, format: string): boolean {
if (!key.circuit_breaker_by_format) return false

View File

@@ -61,9 +61,11 @@
/>
<!-- 模型信息 -->
<div class="text-left flex-1 min-w-0">
<span class="font-semibold text-sm">
{{ model.global_model_display_name || model.provider_model_name }}
</span>
<div class="flex items-center gap-1.5">
<span class="font-semibold text-sm">
{{ model.global_model_display_name || model.provider_model_name }}
</span>
</div>
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<span class="font-mono truncate">{{ model.provider_model_name }}</span>
<button

View File

@@ -17,6 +17,7 @@ interface CacheEntry {
type FetchResult = { models: UpstreamModel[]; error?: string }
// 全局缓存(模块级别,所有组件共享)
// 支持两种 key: providerId 或 providerId:apiKeyId
const cache = new Map<string, CacheEntry>()
const CACHE_TTL = 5 * 60 * 1000 // 5分钟
@@ -26,39 +27,50 @@ const pendingRequests = new Map<string, Promise<FetchResult>>()
// 请求状态
const loadingMap = ref<Map<string, boolean>>(new Map())
/**
* 生成缓存 key
*/
function getCacheKey(providerId: string, apiKeyId?: string): string {
return apiKeyId ? `${providerId}:${apiKeyId}` : providerId
}
export function useUpstreamModelsCache() {
/**
* 获取上游模型列表
* @param providerId 提供商ID
* @param apiKeyId 可选的 API Key ID用于获取特定 Key 支持的模型)
* @param forceRefresh 是否强制刷新
* @returns 模型列表或 null如果请求失败
*/
async function fetchModels(
providerId: string,
apiKeyId?: string,
forceRefresh = false
): Promise<FetchResult> {
const cacheKey = getCacheKey(providerId, apiKeyId)
// 检查缓存
if (!forceRefresh) {
const cached = cache.get(providerId)
const cached = cache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return { models: cached.models }
}
}
// 检查是否有进行中的请求(非强制刷新时复用)
if (!forceRefresh && pendingRequests.has(providerId)) {
return pendingRequests.get(providerId)!
if (!forceRefresh && pendingRequests.has(cacheKey)) {
return pendingRequests.get(cacheKey)!
}
// 创建新请求
const requestPromise = (async (): Promise<FetchResult> => {
try {
loadingMap.value.set(providerId, true)
const response = await adminApi.queryProviderModels(providerId)
loadingMap.value.set(cacheKey, true)
const response = await adminApi.queryProviderModels(providerId, apiKeyId)
if (response.success && response.data?.models) {
// 存入缓存
cache.set(providerId, {
cache.set(cacheKey, {
models: response.data.models,
timestamp: Date.now()
})
@@ -73,20 +85,21 @@ export function useUpstreamModelsCache() {
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
return { models: [], error: parseUpstreamModelError(rawError) }
} finally {
loadingMap.value.set(providerId, false)
pendingRequests.delete(providerId)
loadingMap.value.set(cacheKey, false)
pendingRequests.delete(cacheKey)
}
})()
pendingRequests.set(providerId, requestPromise)
pendingRequests.set(cacheKey, requestPromise)
return requestPromise
}
/**
* 获取缓存的模型(不发起请求)
*/
function getCachedModels(providerId: string): UpstreamModel[] | null {
const cached = cache.get(providerId)
function getCachedModels(providerId: string, apiKeyId?: string): UpstreamModel[] | null {
const cacheKey = getCacheKey(providerId, apiKeyId)
const cached = cache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.models
}
@@ -94,17 +107,19 @@ export function useUpstreamModelsCache() {
}
/**
* 清除指定提供商的缓存
* 清除指定提供商/Key的缓存
*/
function clearCache(providerId: string) {
cache.delete(providerId)
function clearCache(providerId: string, apiKeyId?: string) {
const cacheKey = getCacheKey(providerId, apiKeyId)
cache.delete(cacheKey)
}
/**
* 检查是否正在加载
*/
function isLoading(providerId: string): boolean {
return loadingMap.value.get(providerId) || false
function isLoading(providerId: string, apiKeyId?: string): boolean {
const cacheKey = getCacheKey(providerId, apiKeyId)
return loadingMap.value.get(cacheKey) || false
}
return {