refactor: 重构上游模型获取与展示逻辑

- 后端支持遍历所有活跃 API Key 聚合模型,按 model id 合并 api_formats
- 前端移除按 API 格式分组,改为统一展示并内联显示格式标签
- 改进 401 错误处理,账户异常时清除认证并重定向首页
This commit is contained in:
fawney19
2026-01-29 22:58:37 +08:00
parent 297da59448
commit 61abb47d22
7 changed files with 262 additions and 162 deletions

View File

@@ -174,9 +174,12 @@ class ApiClient {
const errorDetail = error.response?.data?.detail || ''
log.debug('Got 401 error, attempting token refresh', { errorDetail })
// 检查是否为业务相关的401错误
// 检查是否为业务相关的401错误(用户被禁用/删除等)
if (!isRefreshableAuthError(errorDetail)) {
log.info('401 error but not authentication issue, keeping session', { errorDetail })
log.info('User account issue detected, logging out and redirecting to home', { errorDetail })
this.clearAuth()
// 跳转到首页
window.location.href = '/'
return Promise.reject(error)
}

View File

@@ -630,12 +630,13 @@ export interface GlobalModelListResponse {
/**
* 上游模型(从提供商 API 获取的原始模型)
* 后端已按 model id 聚合api_formats 包含该模型支持的所有 API 格式
*/
export interface UpstreamModel {
id: string
owned_by?: string
display_name?: string
api_format?: string
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
}
/**

View File

@@ -119,36 +119,33 @@
</div>
<!-- 上游模型组 -->
<div
v-for="group in filteredUpstreamGroups"
:key="group.api_format"
>
<div v-if="filteredUpstreamModels.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"
@click="toggleGroupCollapse(group.api_format)"
@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">({{ filteredUpstreamModels.length }})</span>
</div>
<button
type="button"
class="text-xs text-primary hover:underline shrink-0"
@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"
v-for="model in filteredUpstreamModels"
:key="model.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleUpstreamModelSelection(model.id)"
@@ -163,11 +160,23 @@
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ model.id }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.owned_by || model.id }}
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium truncate">
{{ model.id }}
</p>
<span
v-for="fmt in model.api_formats"
:key="fmt"
class="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0"
>
{{ API_FORMAT_LABELS[fmt] || fmt }}
</span>
</div>
<p
v-if="model.owned_by"
class="text-xs text-muted-foreground truncate"
>
{{ model.owned_by }}
</p>
</div>
</div>
@@ -176,7 +185,7 @@
<!-- 空状态 -->
<div
v-if="filteredGlobalModels.length === 0 && filteredUpstreamGroups.length === 0"
v-if="filteredGlobalModels.length === 0 && filteredUpstreamModels.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Layers class="w-10 h-10 mb-2 opacity-30" />
@@ -319,35 +328,43 @@ const filteredGlobalModels = computed(() => {
})
})
// 过滤后的上游模型(按 API 格式分组
const filteredUpstreamGroups = computed(() => {
// 过滤后的上游模型(后端已按 id 聚合
const filteredUpstreamModels = computed(() => {
if (!upstreamModelsLoaded.value) return []
const query = searchQuery.value.toLowerCase().trim()
const groups: Record<string, UpstreamModel[]> = {}
let models = upstreamModels.value
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)
if (query) {
models = models.filter(m => m.id.toLowerCase().includes(query))
}
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
})
// 按 id 排序
return [...models].sort((a, b) => a.id.localeCompare(b.id))
})
// 上游模型是否全选
const isAllUpstreamModelsSelected = computed(() => {
if (filteredUpstreamModels.value.length === 0) return false
return filteredUpstreamModels.value.every(m => selectedUpstreamModelIds.value.has(m.id))
})
// 全选/取消全选上游模型
function toggleAllUpstreamModels() {
const allIds = filteredUpstreamModels.value.map(m => m.id)
if (isAllUpstreamModelsSelected.value) {
// 取消全选
for (const id of allIds) {
selectedUpstreamModelIds.value.delete(id)
}
} else {
// 全选
for (const id of allIds) {
selectedUpstreamModelIds.value.add(id)
}
}
}
// 检查全局模型是否已选中
function isGlobalModelSelected(globalModelId: string): boolean {
return selectedGlobalModelIds.value.has(globalModelId)
@@ -364,13 +381,6 @@ const isAllGlobalModelsSelected = computed(() => {
return filteredGlobalModels.value.every(m => isGlobalModelSelected(m.id))
})
// 检查某个上游组是否全选
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 => isUpstreamModelSelected(m.id))
}
// 计算待添加的全局模型
const globalModelsToAdd = computed(() => {
const toAdd: string[] = []
@@ -468,26 +478,6 @@ function toggleAllGlobalModels() {
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
}
// 全选/取消全选某个上游组
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)) {
// 取消全选
for (const id of allIds) {
selectedUpstreamModelIds.value.delete(id)
}
} else {
// 全选
for (const id of allIds) {
selectedUpstreamModelIds.value.add(id)
}
}
selectedUpstreamModelIds.value = new Set(selectedUpstreamModelIds.value)
}
// 切换折叠状态
function toggleGroupCollapse(group: string) {
if (collapsedGroups.value.has(group)) {
@@ -672,14 +662,8 @@ async function fetchUpstreamModels(forceRefresh = false) {
upstreamModelsLoaded.value = true
// 同步上游模型选择状态
syncUpstreamModelSelection()
// 有多个分组时全部折叠
const allGroups = new Set(['global'])
for (const model of result.models) {
if (model.api_format) {
allGroups.add(model.api_format)
}
}
collapsedGroups.value = allGroups
// 全部折叠
collapsedGroups.value = new Set(['global', 'upstream'])
}
}
} finally {

View File

@@ -2,7 +2,7 @@
<Dialog
:model-value="isOpen"
title="获取上游模型"
:description="`使用密钥 ${props.apiKey?.name || props.apiKey?.api_key_masked || ''} 从上游获取模型列表。导入的模型需要关联全局模型后才能参与路由。`"
description="从上游获取所有密钥可用的模型列表。导入的模型需要关联全局模型后才能参与路由。"
:icon="Layers"
size="2xl"
@update:model-value="handleDialogUpdate"
@@ -100,7 +100,7 @@
<div class="max-h-[320px] overflow-y-auto pr-1 space-y-1 custom-scrollbar">
<div
v-for="model in upstreamModels"
:key="`${model.id}:${model.api_format || ''}`"
:key="model.id"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200 cursor-pointer select-none"
:class="[
selectedModels.includes(model.id)
@@ -121,11 +121,12 @@
{{ model.display_name || model.id }}
</span>
<Badge
v-if="model.api_format"
v-for="fmt in model.api_formats"
:key="fmt"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
>
{{ API_FORMAT_LABELS[model.api_format] || model.api_format }}
{{ API_FORMAT_LABELS[fmt] || fmt }}
</Badge>
<Badge
v-if="isModelExisting(model.id)"
@@ -274,15 +275,16 @@ async function loadExistingModels() {
}
}
// 获取上游模型
// 获取上游模型(获取所有 Key 的聚合结果)
async function fetchUpstreamModels() {
if (!props.providerId || !props.apiKey) return
if (!props.providerId) return
loading.value = true
errorMessage.value = ''
try {
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
// 不传 apiKeyId后端会遍历所有 Key 并聚合结果
const response = await adminApi.queryProviderModels(props.providerId)
if (response.success && response.data?.models) {
upstreamModels.value = response.data.models

View File

@@ -468,28 +468,10 @@ function isGlobalModel(modelId: string): boolean {
return globalModelNamesSet.value.has(modelId)
}
// 上游模型信息(包含 api_format
interface UpstreamModelInfo {
id: string
api_formats: string[] // 该模型支持的所有 API 格式
}
// 上游模型列表(按 id 聚合,包含所有 api_format
// 上游模型列表(后端已按 id 聚合,包含 api_formats 数组
// 这里只做排序
const upstreamModelList = computed(() => {
const modelMap = new Map<string, Set<string>>()
upstreamModels.value.forEach(m => {
if (!modelMap.has(m.id)) {
modelMap.set(m.id, new Set())
}
if (m.api_format) {
modelMap.get(m.id)!.add(m.api_format)
}
})
const result: UpstreamModelInfo[] = []
modelMap.forEach((formats, id) => {
result.push({ id, api_formats: sortApiFormats(Array.from(formats)) })
})
return result.sort((a, b) => a.id.localeCompare(b.id))
return [...upstreamModels.value].sort((a, b) => a.id.localeCompare(b.id))
})
// 上游模型名称列表(用于计数和全选判断)
@@ -687,11 +669,13 @@ async function loadGlobalModels() {
}
// 从提供商获取模型(使用缓存)
// 不传 apiKeyId获取所有 Key 的聚合结果
async function fetchUpstreamModels(forceRefresh = false) {
if (!props.providerId || !props.apiKey) return
try {
fetchingUpstreamModels.value = true
const result = await fetchCachedModels(props.providerId, props.apiKey.id, forceRefresh)
// 不传 apiKeyId后端会遍历所有 Key 并聚合结果
const result = await fetchCachedModels(props.providerId, undefined, forceRefresh)
if (loadingCancelled) return
if (result.models.length > 0) {
upstreamModels.value = result.models

View File

@@ -264,7 +264,8 @@ import {
import { useToast } from '@/composables/useToast'
import {
type Model,
type ProviderModelAlias
type ProviderModelAlias,
type UpstreamModel,
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
@@ -311,7 +312,7 @@ const searchQuery = ref('')
const collapsedGroups = ref<Set<string>>(new Set())
// 上游模型
const upstreamModels = ref<{ id: string; api_format?: string }[]>([])
const upstreamModels = ref<UpstreamModel[]>([])
// 表单数据
const formData = ref<{