mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-11 20:18:30 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94abab3260 | ||
|
|
76ed136228 | ||
|
|
8d8b20aa47 | ||
|
|
aec0326d40 |
@@ -22,7 +22,7 @@
|
|||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0 pointer-events-none">
|
<div class="relative flex min-h-full items-end justify-center px-3 py-4 text-center sm:items-center sm:p-0 pointer-events-none">
|
||||||
<!-- 对话框内容 -->
|
<!-- 对话框内容 -->
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="duration-300 ease-out"
|
enter-active-class="duration-300 ease-out"
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border pointer-events-auto"
|
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all w-full sm:my-8 sm:w-full border border-border pointer-events-auto"
|
||||||
:style="{ zIndex: contentZIndex }"
|
:style="{ zIndex: contentZIndex }"
|
||||||
:class="maxWidthClass"
|
:class="maxWidthClass"
|
||||||
@click.stop
|
@click.stop
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -187,6 +187,7 @@ import Button from '@/components/ui/button.vue'
|
|||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Checkbox from '@/components/ui/checkbox.vue'
|
import Checkbox from '@/components/ui/checkbox.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { parseUpstreamModelError } from '@/utils/errorParser'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import {
|
import {
|
||||||
importModelsFromUpstream,
|
importModelsFromUpstream,
|
||||||
@@ -289,13 +290,18 @@ async function fetchUpstreamModels() {
|
|||||||
hasQueried.value = true
|
hasQueried.value = true
|
||||||
// 如果有部分失败,显示警告提示
|
// 如果有部分失败,显示警告提示
|
||||||
if (response.data.error) {
|
if (response.data.error) {
|
||||||
showError(`部分格式获取失败: ${response.data.error}`, '警告')
|
// 使用友好的错误解析
|
||||||
|
showError(`部分格式获取失败: ${parseUpstreamModelError(response.data.error)}`, '警告')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = response.data?.error || '获取上游模型失败'
|
// 使用友好的错误解析
|
||||||
|
const rawError = response.data?.error || '获取上游模型失败'
|
||||||
|
errorMessage.value = parseUpstreamModelError(rawError)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errorMessage.value = err.response?.data?.detail || '获取上游模型失败'
|
// 使用友好的错误解析
|
||||||
|
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
|
||||||
|
errorMessage.value = parseUpstreamModelError(rawError)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
:model-value="isOpen"
|
:model-value="isOpen"
|
||||||
title="模型权限"
|
:title="props.apiKey?.name ? `模型权限 - ${props.apiKey.name}` : '模型权限'"
|
||||||
:description="`管理密钥 ${props.apiKey?.name || ''} 可访问的模型,清空右侧列表表示允许全部`"
|
description="选中的模型将被允许访问,不选择则允许全部"
|
||||||
:icon="Shield"
|
:icon="Shield"
|
||||||
size="4xl"
|
size="2xl"
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
@@ -20,38 +20,31 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 密钥信息头部 -->
|
<!-- 常驻选择面板 -->
|
||||||
<div class="rounded-lg border bg-muted/30 p-4">
|
<div class="border rounded-lg overflow-hidden">
|
||||||
<div class="flex items-start justify-between">
|
<!-- 搜索 + 操作栏 -->
|
||||||
<div>
|
<div class="flex items-center gap-2 p-2 border-b bg-muted/30">
|
||||||
<p class="font-semibold text-lg">{{ apiKey?.name }}</p>
|
<div class="relative flex-1">
|
||||||
<p class="text-sm text-muted-foreground font-mono">
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
{{ apiKey?.api_key_masked }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
:variant="allowedModels.length === 0 ? 'default' : 'outline'"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ allowedModels.length === 0 ? '允许全部' : `限制 ${allowedModels.length} 个模型` }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 左右对比布局 -->
|
|
||||||
<div class="flex gap-2 items-stretch">
|
|
||||||
<!-- 左侧:可添加的模型 -->
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<p class="text-sm font-medium shrink-0">可添加</p>
|
|
||||||
<div class="flex-1 relative">
|
|
||||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<Input
|
<Input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="搜索模型..."
|
placeholder="搜索模型或添加自定义模型..."
|
||||||
class="pl-7 h-7 text-xs"
|
class="pl-8 h-8 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 已选数量徽章 -->
|
||||||
|
<span
|
||||||
|
v-if="selectedModels.length === 0"
|
||||||
|
class="h-6 px-2 text-xs rounded flex items-center bg-muted text-muted-foreground 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
|
<button
|
||||||
v-if="upstreamModelsLoaded"
|
v-if="upstreamModelsLoaded"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -61,113 +54,143 @@
|
|||||||
@click="fetchUpstreamModels()"
|
@click="fetchUpstreamModels()"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
class="w-3.5 h-3.5"
|
class="w-4 h-4"
|
||||||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Button
|
||||||
v-else-if="!fetchingUpstreamModels"
|
v-else-if="!fetchingUpstreamModels"
|
||||||
type="button"
|
variant="outline"
|
||||||
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
size="sm"
|
||||||
title="从提供商获取模型"
|
class="h-8"
|
||||||
|
title="从提供<E68F90><E4BE9B><EFBFBD>获取模型"
|
||||||
@click="fetchUpstreamModels()"
|
@click="fetchUpstreamModels()"
|
||||||
>
|
>
|
||||||
<Zap class="w-3.5 h-3.5" />
|
<Zap class="w-4 h-4" />
|
||||||
</button>
|
</Button>
|
||||||
<Loader2
|
<Loader2
|
||||||
v-else
|
v-else
|
||||||
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
|
||||||
|
<!-- 分组列表 -->
|
||||||
|
<div class="max-h-96 overflow-y-auto">
|
||||||
|
<!-- 加载中 -->
|
||||||
<div
|
<div
|
||||||
v-if="loadingGlobalModels"
|
v-if="loadingGlobalModels"
|
||||||
class="flex items-center justify-center h-full"
|
class="flex items-center justify-center py-12"
|
||||||
>
|
>
|
||||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 添加自定义模型(搜索内容不在列表中时显示,固定在顶部) -->
|
||||||
<div
|
<div
|
||||||
v-else-if="totalAvailableCount === 0 && !upstreamModelsLoaded"
|
v-if="searchQuery && canAddAsCustom"
|
||||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
class="px-3 py-2 border-b bg-background sticky top-0 z-10"
|
||||||
>
|
>
|
||||||
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
<div
|
||||||
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可添加模型' }}</p>
|
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"
|
||||||
|
@click="addCustomModel"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Plus class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-mono">{{ searchQuery }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="p-2 space-y-2">
|
<span class="text-xs text-muted-foreground">添加自定义模型</span>
|
||||||
<!-- 全局模型折叠组 -->
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义模型(手动添加的,始终显示全部,搜索命中的排前面) -->
|
||||||
|
<div v-if="customModels.length > 0">
|
||||||
<div
|
<div
|
||||||
v-if="availableGlobalModels.length > 0 || !upstreamModelsLoaded"
|
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="border rounded-lg overflow-hidden"
|
@click="toggleGroupCollapse('custom')"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<ChevronDown
|
||||||
type="button"
|
class="w-4 h-4 transition-transform shrink-0"
|
||||||
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
:class="collapsedGroups.has('custom') ? '-rotate-90' : ''"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium">自定义模型</span>
|
||||||
|
<span class="text-xs text-muted-foreground">({{ customModels.length }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-show="!collapsedGroups.has('custom')"
|
||||||
|
class="space-y-1 p-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="model in sortedCustomModels"
|
||||||
|
:key="model"
|
||||||
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||||
|
@click="toggleModel(model)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||||||
|
:class="selectedModels.includes(model) ? 'bg-primary border-primary' : ''"
|
||||||
|
>
|
||||||
|
<Check v-if="selectedModels.includes(model)" class="w-3 h-3 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-mono truncate">{{ model }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 全局模型 -->
|
||||||
|
<div v-if="filteredGlobalModels.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('global')"
|
@click="toggleGroupCollapse('global')"
|
||||||
>
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="w-4 h-4 transition-transform shrink-0"
|
class="w-4 h-4 transition-transform shrink-0"
|
||||||
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
: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">
|
<span class="text-xs text-muted-foreground">({{ filteredGlobalModels.length }})</span>
|
||||||
({{ availableGlobalModels.length }})
|
</div>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="availableGlobalModels.length > 0"
|
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-primary hover:underline shrink-0"
|
class="text-xs text-primary hover:underline"
|
||||||
@click.stop="selectAllGlobalModels"
|
@click.stop="toggleAllGlobalModels"
|
||||||
>
|
>
|
||||||
{{ isAllGlobalModelsSelected ? '取消' : '全选' }}
|
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-show="!collapsedGroups.has('global')"
|
v-show="!collapsedGroups.has('global')"
|
||||||
class="p-2 space-y-1 border-t"
|
class="space-y-1 p-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="availableGlobalModels.length === 0"
|
v-for="model in filteredGlobalModels"
|
||||||
class="py-4 text-center text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
所有全局模型均已添加
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="model in availableGlobalModels"
|
|
||||||
v-else
|
|
||||||
:key="model.name"
|
:key="model.name"
|
||||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||||
:class="selectedLeftIds.includes(model.name)
|
@click="toggleModel(model.name)"
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: 'hover:bg-muted/50'"
|
|
||||||
@click="toggleLeftSelection(model.name)"
|
|
||||||
>
|
>
|
||||||
<Checkbox
|
<div
|
||||||
:checked="selectedLeftIds.includes(model.name)"
|
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||||||
@update:checked="toggleLeftSelection(model.name)"
|
:class="selectedModels.includes(model.name) ? 'bg-primary border-primary' : ''"
|
||||||
@click.stop
|
>
|
||||||
/>
|
<Check v-if="selectedModels.includes(model.name)" class="w-3 h-3 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium text-sm truncate">{{ model.display_name }}</p>
|
<p class="text-sm font-medium truncate">{{ model.display_name }}</p>
|
||||||
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
|
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 从提供商获取的模型折叠组 -->
|
<!-- 上游模型组 -->
|
||||||
|
<div v-for="group in filteredUpstreamGroups" :key="group.api_format">
|
||||||
<div
|
<div
|
||||||
v-for="group in upstreamModelGroups"
|
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"
|
||||||
:key="group.api_format"
|
|
||||||
class="border rounded-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
|
||||||
@click="toggleGroupCollapse(group.api_format)"
|
@click="toggleGroupCollapse(group.api_format)"
|
||||||
>
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="w-4 h-4 transition-transform shrink-0"
|
class="w-4 h-4 transition-transform shrink-0"
|
||||||
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
|
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
|
||||||
@@ -175,130 +198,47 @@
|
|||||||
<span class="text-xs font-medium">
|
<span class="text-xs font-medium">
|
||||||
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">({{ group.models.length }})</span>
|
||||||
({{ group.models.length }})
|
</div>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-primary hover:underline shrink-0"
|
class="text-xs text-primary hover:underline"
|
||||||
@click.stop="selectAllUpstreamModels(group.api_format)"
|
@click.stop="toggleAllUpstreamGroup(group.api_format)"
|
||||||
>
|
>
|
||||||
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消' : '全选' }}
|
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消全选' : '全选' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-show="!collapsedGroups.has(group.api_format)"
|
v-show="!collapsedGroups.has(group.api_format)"
|
||||||
class="p-2 space-y-1 border-t"
|
class="space-y-1 p-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="model in group.models"
|
v-for="model in group.models"
|
||||||
:key="model.id"
|
:key="model.id"
|
||||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||||
:class="selectedLeftIds.includes(model.id)
|
@click="toggleModel(model.id)"
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: 'hover:bg-muted/50'"
|
|
||||||
@click="toggleLeftSelection(model.id)"
|
|
||||||
>
|
>
|
||||||
<Checkbox
|
|
||||||
:checked="selectedLeftIds.includes(model.id)"
|
|
||||||
@update:checked="toggleLeftSelection(model.id)"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="font-medium text-sm truncate">{{ model.id }}</p>
|
|
||||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
|
||||||
{{ model.owned_by || model.id }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 中间:操作按钮 -->
|
|
||||||
<div class="flex flex-col items-center justify-center w-12 shrink-0 gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="w-9 h-8"
|
|
||||||
:class="selectedLeftIds.length > 0 ? 'border-primary' : ''"
|
|
||||||
:disabled="selectedLeftIds.length === 0"
|
|
||||||
title="添加选中"
|
|
||||||
@click="addSelected"
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
class="w-6 h-6 stroke-[3]"
|
|
||||||
:class="selectedLeftIds.length > 0 ? 'text-primary' : ''"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="w-9 h-8"
|
|
||||||
:class="selectedRightIds.length > 0 ? 'border-primary' : ''"
|
|
||||||
:disabled="selectedRightIds.length === 0"
|
|
||||||
title="移除选中"
|
|
||||||
@click="removeSelected"
|
|
||||||
>
|
|
||||||
<ChevronLeft
|
|
||||||
class="w-6 h-6 stroke-[3]"
|
|
||||||
:class="selectedRightIds.length > 0 ? 'text-primary' : ''"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧:已添加的允许模型 -->
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<p class="text-sm font-medium">已添加</p>
|
|
||||||
<Button
|
|
||||||
v-if="allowedModels.length > 0"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-6 px-2 text-xs"
|
|
||||||
@click="toggleSelectAllRight"
|
|
||||||
>
|
|
||||||
{{ isAllRightSelected ? '取消' : '全选' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
|
||||||
<div
|
<div
|
||||||
v-if="allowedModels.length === 0"
|
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
:class="selectedModels.includes(model.id) ? 'bg-primary border-primary' : ''"
|
||||||
|
>
|
||||||
|
<Check v-if="selectedModels.includes(model.id)" class="w-3 h-3 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-mono truncate">{{ model.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-if="filteredGlobalModels.length === 0 && filteredUpstreamGroups.length === 0 && customModels.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
||||||
<p class="text-sm">允许访问全部模型</p>
|
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}</p>
|
||||||
<p class="text-xs mt-1">添加模型以限制访问范围</p>
|
<p v-if="!upstreamModelsLoaded" class="text-xs mt-1">点击闪电按钮从上游获取模型</p>
|
||||||
</div>
|
|
||||||
<div v-else class="p-2 space-y-1">
|
|
||||||
<div
|
|
||||||
v-for="modelName in allowedModels"
|
|
||||||
:key="'allowed-' + modelName"
|
|
||||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
|
||||||
:class="selectedRightIds.includes(modelName)
|
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: 'hover:bg-muted/50'"
|
|
||||||
@click="toggleRightSelection(modelName)"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
:checked="selectedRightIds.includes(modelName)"
|
|
||||||
@update:checked="toggleRightSelection(modelName)"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="font-medium text-sm truncate">
|
|
||||||
{{ getModelDisplayName(modelName) }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
|
||||||
{{ modelName }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,10 +250,10 @@
|
|||||||
{{ hasChanges ? '有未保存的更改' : '' }}
|
{{ hasChanges ? '有未保存的更改' : '' }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button variant="outline" @click="handleCancel">取消</Button>
|
|
||||||
<Button :disabled="saving || !hasChanges" @click="handleSave">
|
<Button :disabled="saving || !hasChanges" @click="handleSave">
|
||||||
{{ saving ? '保存中...' : '保存' }}
|
{{ saving ? '保存中...' : '保存' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" @click="handleCancel">取消</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -328,13 +268,14 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Loader2,
|
Loader2,
|
||||||
Zap,
|
Zap,
|
||||||
ChevronRight,
|
Plus,
|
||||||
ChevronLeft,
|
Check,
|
||||||
ChevronDown
|
ChevronDown
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Dialog, Button, Input, Checkbox, Badge } from '@/components/ui'
|
import { Dialog, Button, Input } from '@/components/ui'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { parseApiError, parseUpstreamModelError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
updateProviderKey,
|
updateProviderKey,
|
||||||
API_FORMAT_LABELS,
|
API_FORMAT_LABELS,
|
||||||
@@ -362,6 +303,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
const { confirmWarning } = useConfirm()
|
||||||
|
|
||||||
const isOpen = computed(() => props.open)
|
const isOpen = computed(() => props.open)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -375,73 +317,107 @@ let loadingCancelled = false
|
|||||||
// 搜索
|
// 搜索
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// 折叠状态
|
|
||||||
const collapsedGroups = ref<Set<string>>(new Set())
|
|
||||||
|
|
||||||
// 可用模型列表(全局模型)
|
// 可用模型列表(全局模型)
|
||||||
const allGlobalModels = ref<AvailableModel[]>([])
|
const allGlobalModels = ref<AvailableModel[]>([])
|
||||||
// 上游模型列表
|
// 上游模型列表
|
||||||
const upstreamModels = ref<UpstreamModel[]>([])
|
const upstreamModels = ref<UpstreamModel[]>([])
|
||||||
|
|
||||||
// 已添加的允许模型(右侧)
|
// 已选中的模型
|
||||||
const allowedModels = ref<string[]>([])
|
const selectedModels = ref<string[]>([])
|
||||||
const initialAllowedModels = ref<string[]>([])
|
const initialSelectedModels = ref<string[]>([])
|
||||||
|
|
||||||
// 选中状态
|
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
|
||||||
const selectedLeftIds = ref<string[]>([])
|
const allCustomModels = ref<string[]>([])
|
||||||
const selectedRightIds = ref<string[]>([])
|
|
||||||
|
// 是否为字典模式(按 API 格式区分)
|
||||||
|
const isDictMode = ref(false)
|
||||||
|
|
||||||
|
// 折叠状态
|
||||||
|
const collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
// 是否有更改
|
// 是否有更改
|
||||||
const hasChanges = computed(() => {
|
const hasChanges = computed(() => {
|
||||||
if (allowedModels.value.length !== initialAllowedModels.value.length) return true
|
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
|
||||||
const sorted1 = [...allowedModels.value].sort()
|
const sorted1 = [...selectedModels.value].sort()
|
||||||
const sorted2 = [...initialAllowedModels.value].sort()
|
const sorted2 = [...initialSelectedModels.value].sort()
|
||||||
return sorted1.some((v, i) => v !== sorted2[i])
|
return sorted1.some((v, i) => v !== sorted2[i])
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算可添加的全局模型(排除已添加的)
|
// 所有已知模型的集合(全局 + 上游)
|
||||||
const availableGlobalModelsBase = computed(() => {
|
const allKnownModels = computed(() => {
|
||||||
const allowedSet = new Set(allowedModels.value)
|
const set = new Set<string>()
|
||||||
return allGlobalModels.value.filter(m => !allowedSet.has(m.name))
|
allGlobalModels.value.forEach(m => set.add(m.name))
|
||||||
|
upstreamModels.value.forEach(m => set.add(m.id))
|
||||||
|
return set
|
||||||
|
})
|
||||||
|
|
||||||
|
// 自定义模型列表(显示所有添加过的,不因取消勾选而消失)
|
||||||
|
const customModels = computed(() => {
|
||||||
|
return allCustomModels.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 排序后的自定义模型(搜索命中的排前面)
|
||||||
|
const sortedCustomModels = computed(() => {
|
||||||
|
const search = searchQuery.value.toLowerCase().trim()
|
||||||
|
if (!search) return customModels.value
|
||||||
|
|
||||||
|
const matched: string[] = []
|
||||||
|
const unmatched: string[] = []
|
||||||
|
for (const m of customModels.value) {
|
||||||
|
if (m.toLowerCase().includes(search)) {
|
||||||
|
matched.push(m)
|
||||||
|
} else {
|
||||||
|
unmatched.push(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...matched, ...unmatched]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 判断搜索内容是否可以作为自定义模型添加
|
||||||
|
const canAddAsCustom = computed(() => {
|
||||||
|
const search = searchQuery.value.trim()
|
||||||
|
if (!search) return false
|
||||||
|
// 已经选中了就不显示
|
||||||
|
if (selectedModels.value.includes(search)) return false
|
||||||
|
// 已经在自定义模型列表中就不显示
|
||||||
|
if (allCustomModels.value.includes(search)) return false
|
||||||
|
// 精确匹配全局模型就不显示
|
||||||
|
if (allGlobalModels.value.some(m => m.name === search)) return false
|
||||||
|
// 精确匹配上游模型就不显示
|
||||||
|
if (upstreamModels.value.some(m => m.id === search)) return false
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 搜索过滤后的全局模型
|
// 搜索过滤后的全局模型
|
||||||
const availableGlobalModels = computed(() => {
|
const filteredGlobalModels = computed(() => {
|
||||||
if (!searchQuery.value.trim()) return availableGlobalModelsBase.value
|
if (!searchQuery.value.trim()) return allGlobalModels.value
|
||||||
const query = searchQuery.value.toLowerCase()
|
const query = searchQuery.value.toLowerCase()
|
||||||
return availableGlobalModelsBase.value.filter(m =>
|
return allGlobalModels.value.filter(m =>
|
||||||
m.name.toLowerCase().includes(query) ||
|
m.name.toLowerCase().includes(query) ||
|
||||||
m.display_name.toLowerCase().includes(query)
|
m.display_name.toLowerCase().includes(query)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算可添加的上游模型(排除已添加的)
|
// 按 API 格式分组的上游模型(过滤后)
|
||||||
const availableUpstreamModelsBase = computed(() => {
|
const filteredUpstreamGroups = computed(() => {
|
||||||
const allowedSet = new Set(allowedModels.value)
|
if (!upstreamModelsLoaded.value) return []
|
||||||
return upstreamModels.value.filter(m => !allowedSet.has(m.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 搜索过滤后的上游模型
|
const query = searchQuery.value.toLowerCase().trim()
|
||||||
const availableUpstreamModels = computed(() => {
|
|
||||||
if (!searchQuery.value.trim()) return availableUpstreamModelsBase.value
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
return availableUpstreamModelsBase.value.filter(m =>
|
|
||||||
m.id.toLowerCase().includes(query) ||
|
|
||||||
(m.owned_by && m.owned_by.toLowerCase().includes(query))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按 API 格式分组的上游模型
|
|
||||||
const upstreamModelGroups = computed(() => {
|
|
||||||
const groups: Record<string, UpstreamModel[]> = {}
|
const groups: Record<string, UpstreamModel[]> = {}
|
||||||
for (const model of availableUpstreamModels.value) {
|
|
||||||
|
for (const model of upstreamModels.value) {
|
||||||
|
// 搜索过滤
|
||||||
|
if (query && !model.id.toLowerCase().includes(query)) continue
|
||||||
|
|
||||||
const format = model.api_format || 'unknown'
|
const format = model.api_format || 'unknown'
|
||||||
if (!groups[format]) groups[format] = []
|
if (!groups[format]) groups[format] = []
|
||||||
groups[format].push(model)
|
groups[format].push(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = Object.keys(API_FORMAT_LABELS)
|
const order = Object.keys(API_FORMAT_LABELS)
|
||||||
return Object.entries(groups)
|
return Object.entries(groups)
|
||||||
.map(([api_format, models]) => ({ api_format, models }))
|
.map(([api_format, models]) => ({ api_format, models }))
|
||||||
|
.filter(g => g.models.length > 0)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aIndex = order.indexOf(a.api_format)
|
const aIndex = order.indexOf(a.api_format)
|
||||||
const bIndex = order.indexOf(b.api_format)
|
const bIndex = order.indexOf(b.api_format)
|
||||||
@@ -452,83 +428,74 @@ const upstreamModelGroups = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 总可添加数量
|
|
||||||
const totalAvailableCount = computed(() => {
|
|
||||||
return availableGlobalModels.value.length + availableUpstreamModels.value.length
|
|
||||||
})
|
|
||||||
|
|
||||||
// 右侧全选状态
|
|
||||||
const isAllRightSelected = computed(() =>
|
|
||||||
allowedModels.value.length > 0 &&
|
|
||||||
selectedRightIds.value.length === allowedModels.value.length
|
|
||||||
)
|
|
||||||
|
|
||||||
// 全局模型是否全选
|
// 全局模型是否全选
|
||||||
const isAllGlobalModelsSelected = computed(() => {
|
const isAllGlobalModelsSelected = computed(() => {
|
||||||
if (availableGlobalModels.value.length === 0) return false
|
if (filteredGlobalModels.value.length === 0) return false
|
||||||
return availableGlobalModels.value.every(m => selectedLeftIds.value.includes(m.name))
|
return filteredGlobalModels.value.every(m => selectedModels.value.includes(m.name))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检查某个上游组是否全选
|
// 检查某个上游组是否全选
|
||||||
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
|
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
|
||||||
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
|
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
|
||||||
if (!group || group.models.length === 0) return false
|
if (!group || group.models.length === 0) return false
|
||||||
return group.models.every(m => selectedLeftIds.value.includes(m.id))
|
return group.models.every(m => selectedModels.value.includes(m.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取模型显示名称
|
// 切换模型选中状态
|
||||||
function getModelDisplayName(name: string): string {
|
function toggleModel(modelId: string) {
|
||||||
const globalModel = allGlobalModels.value.find(m => m.name === name)
|
const idx = selectedModels.value.indexOf(modelId)
|
||||||
if (globalModel) return globalModel.display_name
|
if (idx === -1) {
|
||||||
const upstreamModel = upstreamModels.value.find(m => m.id === name)
|
selectedModels.value.push(modelId)
|
||||||
if (upstreamModel) return upstreamModel.id
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载全局模型
|
|
||||||
async function loadGlobalModels() {
|
|
||||||
loadingGlobalModels.value = true
|
|
||||||
try {
|
|
||||||
const response = await getGlobalModels({ limit: 1000 })
|
|
||||||
// 检查是否已取消(dialog 已关闭)
|
|
||||||
if (loadingCancelled) return
|
|
||||||
allGlobalModels.value = response.models.map((m: GlobalModelResponse) => ({
|
|
||||||
name: m.name,
|
|
||||||
display_name: m.display_name
|
|
||||||
}))
|
|
||||||
} catch (err) {
|
|
||||||
if (loadingCancelled) return
|
|
||||||
showError('加载全局模型失败', '错误')
|
|
||||||
} finally {
|
|
||||||
loadingGlobalModels.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从提供商获取模型(使用当前 key)
|
|
||||||
async function fetchUpstreamModels() {
|
|
||||||
if (!props.providerId || !props.apiKey) return
|
|
||||||
try {
|
|
||||||
fetchingUpstreamModels.value = true
|
|
||||||
// 使用当前 key 的 ID 来查询上游模型
|
|
||||||
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
|
|
||||||
// 检查是否已取消
|
|
||||||
if (loadingCancelled) return
|
|
||||||
if (response.success && response.data?.models) {
|
|
||||||
upstreamModels.value = response.data.models
|
|
||||||
upstreamModelsLoaded.value = true
|
|
||||||
const allGroups = new Set(['global'])
|
|
||||||
for (const model of response.data.models) {
|
|
||||||
if (model.api_format) allGroups.add(model.api_format)
|
|
||||||
}
|
|
||||||
collapsedGroups.value = allGroups
|
|
||||||
} else {
|
} else {
|
||||||
showError(response.data?.error || '获取上游模型失败', '错误')
|
selectedModels.value.splice(idx, 1)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
}
|
||||||
if (loadingCancelled) return
|
|
||||||
showError(err.response?.data?.detail || '获取上游模型失败', '错误')
|
// 添加自定义模型
|
||||||
} finally {
|
function addCustomModel() {
|
||||||
fetchingUpstreamModels.value = false
|
const model = searchQuery.value.trim()
|
||||||
|
if (model && !selectedModels.value.includes(model)) {
|
||||||
|
selectedModels.value.push(model)
|
||||||
|
// 同时添加到自定义模型列表(如果不在已知模型中)
|
||||||
|
if (!allKnownModels.value.has(model) && !allCustomModels.value.includes(model)) {
|
||||||
|
allCustomModels.value.push(model)
|
||||||
|
}
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选/取消全选全局模型
|
||||||
|
function toggleAllGlobalModels() {
|
||||||
|
const allNames = filteredGlobalModels.value.map(m => m.name)
|
||||||
|
if (isAllGlobalModelsSelected.value) {
|
||||||
|
// 取消全选
|
||||||
|
selectedModels.value = selectedModels.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,8 +509,51 @@ function toggleGroupCollapse(group: string) {
|
|||||||
collapsedGroups.value = new Set(collapsedGroups.value)
|
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 是否为字典模式(按 API 格式区分)
|
// 加载全局模型
|
||||||
const isDictMode = ref(false)
|
async function loadGlobalModels() {
|
||||||
|
loadingGlobalModels.value = true
|
||||||
|
try {
|
||||||
|
const response = await getGlobalModels({ limit: 1000 })
|
||||||
|
if (loadingCancelled) return
|
||||||
|
allGlobalModels.value = response.models.map((m: GlobalModelResponse) => ({
|
||||||
|
name: m.name,
|
||||||
|
display_name: m.display_name
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
if (loadingCancelled) return
|
||||||
|
showError('加载全局模型失败', '错误')
|
||||||
|
} finally {
|
||||||
|
loadingGlobalModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从提供商获取模型
|
||||||
|
async function fetchUpstreamModels() {
|
||||||
|
if (!props.providerId || !props.apiKey) return
|
||||||
|
try {
|
||||||
|
fetchingUpstreamModels.value = true
|
||||||
|
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
|
||||||
|
if (loadingCancelled) return
|
||||||
|
if (response.success && response.data?.models) {
|
||||||
|
upstreamModels.value = response.data.models
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
|
||||||
|
const upstreamIds = new Set(response.data.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, '获取上游模型失败')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (loadingCancelled) return
|
||||||
|
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
|
||||||
|
showError(parseUpstreamModelError(rawError), '获取上游模型失败')
|
||||||
|
} finally {
|
||||||
|
fetchingUpstreamModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析 allowed_models
|
// 解析 allowed_models
|
||||||
function parseAllowedModels(allowed: AllowedModels): string[] {
|
function parseAllowedModels(allowed: AllowedModels): string[] {
|
||||||
@@ -564,98 +574,25 @@ function parseAllowedModels(allowed: AllowedModels): string[] {
|
|||||||
return Array.from(all)
|
return Array.from(all)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 左侧选择
|
|
||||||
function toggleLeftSelection(name: string) {
|
|
||||||
const idx = selectedLeftIds.value.indexOf(name)
|
|
||||||
if (idx === -1) {
|
|
||||||
selectedLeftIds.value.push(name)
|
|
||||||
} else {
|
|
||||||
selectedLeftIds.value.splice(idx, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 右侧选择
|
|
||||||
function toggleRightSelection(name: string) {
|
|
||||||
const idx = selectedRightIds.value.indexOf(name)
|
|
||||||
if (idx === -1) {
|
|
||||||
selectedRightIds.value.push(name)
|
|
||||||
} else {
|
|
||||||
selectedRightIds.value.splice(idx, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 右侧全选切换
|
|
||||||
function toggleSelectAllRight() {
|
|
||||||
if (isAllRightSelected.value) {
|
|
||||||
selectedRightIds.value = []
|
|
||||||
} else {
|
|
||||||
selectedRightIds.value = [...allowedModels.value]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全选全局模型
|
|
||||||
function selectAllGlobalModels() {
|
|
||||||
const allNames = availableGlobalModels.value.map(m => m.name)
|
|
||||||
const allSelected = allNames.every(name => selectedLeftIds.value.includes(name))
|
|
||||||
if (allSelected) {
|
|
||||||
selectedLeftIds.value = selectedLeftIds.value.filter(id => !allNames.includes(id))
|
|
||||||
} else {
|
|
||||||
const newNames = allNames.filter(name => !selectedLeftIds.value.includes(name))
|
|
||||||
selectedLeftIds.value.push(...newNames)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全选某个 API 格式的上游模型
|
|
||||||
function selectAllUpstreamModels(apiFormat: string) {
|
|
||||||
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
|
|
||||||
if (!group) return
|
|
||||||
const allIds = group.models.map(m => m.id)
|
|
||||||
const allSelected = allIds.every(id => selectedLeftIds.value.includes(id))
|
|
||||||
if (allSelected) {
|
|
||||||
selectedLeftIds.value = selectedLeftIds.value.filter(id => !allIds.includes(id))
|
|
||||||
} else {
|
|
||||||
const newIds = allIds.filter(id => !selectedLeftIds.value.includes(id))
|
|
||||||
selectedLeftIds.value.push(...newIds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加选中的模型到右侧
|
|
||||||
function addSelected() {
|
|
||||||
for (const name of selectedLeftIds.value) {
|
|
||||||
if (!allowedModels.value.includes(name)) {
|
|
||||||
allowedModels.value.push(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectedLeftIds.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从右侧移除选中的模型
|
|
||||||
function removeSelected() {
|
|
||||||
allowedModels.value = allowedModels.value.filter(
|
|
||||||
name => !selectedRightIds.value.includes(name)
|
|
||||||
)
|
|
||||||
selectedRightIds.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听对话框打开
|
// 监听对话框打开
|
||||||
watch(() => props.open, async (open) => {
|
watch(() => props.open, async (open) => {
|
||||||
if (open && props.apiKey) {
|
if (open && props.apiKey) {
|
||||||
// 重置取消标志
|
|
||||||
loadingCancelled = false
|
loadingCancelled = false
|
||||||
|
|
||||||
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
|
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
|
||||||
allowedModels.value = [...parsed]
|
selectedModels.value = [...parsed]
|
||||||
initialAllowedModels.value = [...parsed]
|
initialSelectedModels.value = [...parsed]
|
||||||
selectedLeftIds.value = []
|
|
||||||
selectedRightIds.value = []
|
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
upstreamModels.value = []
|
upstreamModels.value = []
|
||||||
upstreamModelsLoaded.value = false
|
upstreamModelsLoaded.value = false
|
||||||
collapsedGroups.value = new Set()
|
allCustomModels.value = []
|
||||||
|
|
||||||
await loadGlobalModels()
|
await loadGlobalModels()
|
||||||
|
|
||||||
|
// 加载全局模型后,从已选中的模型中提取自定义模型(不在全局模型中的)
|
||||||
|
const globalModelNames = new Set(allGlobalModels.value.map(m => m.name))
|
||||||
|
allCustomModels.value = selectedModels.value.filter(m => !globalModelNames.has(m))
|
||||||
} else {
|
} else {
|
||||||
// dialog 关闭时设置取消标志
|
|
||||||
loadingCancelled = true
|
loadingCancelled = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -665,11 +602,19 @@ onUnmounted(() => {
|
|||||||
loadingCancelled = true
|
loadingCancelled = true
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleDialogUpdate(value: boolean) {
|
async function handleDialogUpdate(value: boolean) {
|
||||||
|
if (!value && hasChanges.value) {
|
||||||
|
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
if (!value) emit('close')
|
if (!value) emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
async function handleCancel() {
|
||||||
|
if (hasChanges.value) {
|
||||||
|
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,9 +623,8 @@ async function handleSave() {
|
|||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
// 空列表 = null(允许全部)
|
const newAllowed: AllowedModels = selectedModels.value.length > 0
|
||||||
const newAllowed: AllowedModels = allowedModels.value.length > 0
|
? [...selectedModels.value]
|
||||||
? [...allowedModels.value]
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
await updateProviderKey(props.apiKey.id, { allowed_models: newAllowed })
|
await updateProviderKey(props.apiKey.id, { allowed_models: newAllowed })
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
:title="isEditMode ? '编辑密钥' : '添加密钥'"
|
:title="isEditMode ? '编辑密钥' : '添加密钥'"
|
||||||
:description="isEditMode ? '修改 API 密钥配置' : '为提供商添加新的 API 密钥'"
|
:description="isEditMode ? '修改 API 密钥配置' : '为提供商添加新的 API 密钥'"
|
||||||
:icon="isEditMode ? SquarePen : Key"
|
:icon="isEditMode ? SquarePen : Key"
|
||||||
size="2xl"
|
size="xl"
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<!-- API 格式选择 -->
|
<!-- API 格式选择 -->
|
||||||
<div v-if="sortedApiFormats.length > 0">
|
<div v-if="sortedApiFormats.length > 0">
|
||||||
<Label class="mb-1.5 block">支持的 API 格式 *</Label>
|
<Label class="mb-1.5 block">支持的 API 格式 *</Label>
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="format in sortedApiFormats"
|
v-for="format in sortedApiFormats"
|
||||||
:key="format"
|
:key="format"
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ const props = defineProps<{
|
|||||||
providerApiFormats: string[]
|
providerApiFormats: string[]
|
||||||
models: Model[]
|
models: Model[]
|
||||||
editingGroup?: AliasGroup | null
|
editingGroup?: AliasGroup | null
|
||||||
|
preselectedModelId?: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -500,7 +501,7 @@ function initForm() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
formData.value = {
|
formData.value = {
|
||||||
modelId: '',
|
modelId: props.preselectedModelId || '',
|
||||||
apiFormats: [],
|
apiFormats: [],
|
||||||
aliases: []
|
aliases: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,26 +168,44 @@
|
|||||||
class="divide-y divide-border/40"
|
class="divide-y divide-border/40"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="{ key, endpoint } in allKeys"
|
v-for="({ key, endpoint }, index) in allKeys"
|
||||||
:key="key.id"
|
:key="key.id"
|
||||||
class="px-4 py-2.5 hover:bg-muted/30 transition-colors"
|
class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item"
|
||||||
|
:class="{
|
||||||
|
'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === index,
|
||||||
|
'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === index && keyDragState.isDragging
|
||||||
|
}"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleKeyDragStart($event, index)"
|
||||||
|
@dragend="handleKeyDragEnd"
|
||||||
|
@dragover="handleKeyDragOver($event, index)"
|
||||||
|
@dragleave="handleKeyDragLeave"
|
||||||
|
@drop="handleKeyDrop($event, index)"
|
||||||
>
|
>
|
||||||
<!-- 第一行:名称 + 状态 + 操作按钮 -->
|
<!-- 第一行:名称 + 状态 + 操作按钮 -->
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<!-- 拖拽手柄 -->
|
||||||
|
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover/item:text-muted-foreground transition-colors shrink-0">
|
||||||
|
<GripVertical class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
|
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
|
||||||
<span class="text-xs font-mono text-muted-foreground">
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-[11px] font-mono text-muted-foreground">
|
||||||
{{ key.api_key_masked }}
|
{{ key.api_key_masked }}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-5 w-5 shrink-0"
|
class="h-4 w-4 shrink-0"
|
||||||
title="复制密钥"
|
title="复制密钥"
|
||||||
@click.stop="copyFullKey(key)"
|
@click.stop="copyFullKey(key)"
|
||||||
>
|
>
|
||||||
<Copy class="w-3 h-3" />
|
<Copy class="w-2.5 h-2.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="!key.is_active"
|
v-if="!key.is_active"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -343,6 +361,7 @@
|
|||||||
@edit-model="handleEditModel"
|
@edit-model="handleEditModel"
|
||||||
@delete-model="handleDeleteModel"
|
@delete-model="handleDeleteModel"
|
||||||
@batch-assign="handleBatchAssign"
|
@batch-assign="handleBatchAssign"
|
||||||
|
@add-mapping="handleAddMapping"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 模型名称映射 -->
|
<!-- 模型名称映射 -->
|
||||||
@@ -570,7 +589,7 @@ const batchAssignDialogOpen = ref(false)
|
|||||||
// ModelAliasesTab 组件引用
|
// ModelAliasesTab 组件引用
|
||||||
const modelAliasesTabRef = ref<InstanceType<typeof ModelAliasesTab> | null>(null)
|
const modelAliasesTabRef = ref<InstanceType<typeof ModelAliasesTab> | null>(null)
|
||||||
|
|
||||||
// 拖动排序相关状态
|
// 拖动排序相关状态(旧的端点级别拖拽,保留以兼容)
|
||||||
const dragState = ref({
|
const dragState = ref({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
draggedKeyId: null as string | null,
|
draggedKeyId: null as string | null,
|
||||||
@@ -578,6 +597,13 @@ const dragState = ref({
|
|||||||
dragEndpointId: null as string | null
|
dragEndpointId: null as string | null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 密钥列表拖拽排序状态
|
||||||
|
const keyDragState = ref({
|
||||||
|
isDragging: false,
|
||||||
|
draggedIndex: null as number | null,
|
||||||
|
targetIndex: null as number | null
|
||||||
|
})
|
||||||
|
|
||||||
// 点击编辑优先级相关状态
|
// 点击编辑优先级相关状态
|
||||||
const editingPriorityKey = ref<string | null>(null)
|
const editingPriorityKey = ref<string | null>(null)
|
||||||
const editingPriorityValue = ref<number>(0)
|
const editingPriorityValue = ref<number>(0)
|
||||||
@@ -930,6 +956,11 @@ function handleBatchAssign() {
|
|||||||
batchAssignDialogOpen.value = true
|
batchAssignDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理添加映射(从 ModelsTab 触发)
|
||||||
|
function handleAddMapping(model: Model) {
|
||||||
|
modelAliasesTabRef.value?.openAddDialogForModel(model.id)
|
||||||
|
}
|
||||||
|
|
||||||
// 处理批量关联完成
|
// 处理批量关联完成
|
||||||
async function handleBatchAssignChanged() {
|
async function handleBatchAssignChanged() {
|
||||||
await loadProvider()
|
await loadProvider()
|
||||||
@@ -1154,6 +1185,92 @@ async function savePriority(key: EndpointAPIKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 密钥列表拖拽排序 =====
|
||||||
|
function handleKeyDragStart(event: DragEvent, index: number) {
|
||||||
|
keyDragState.value.isDragging = true
|
||||||
|
keyDragState.value.draggedIndex = index
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('text/plain', String(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDragEnd() {
|
||||||
|
keyDragState.value.isDragging = false
|
||||||
|
keyDragState.value.draggedIndex = null
|
||||||
|
keyDragState.value.targetIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDragOver(event: DragEvent, index: number) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
if (keyDragState.value.draggedIndex !== index) {
|
||||||
|
keyDragState.value.targetIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDragLeave() {
|
||||||
|
keyDragState.value.targetIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKeyDrop(event: DragEvent, targetIndex: number) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const draggedIndex = keyDragState.value.draggedIndex
|
||||||
|
if (draggedIndex === null || draggedIndex === targetIndex) {
|
||||||
|
handleKeyDragEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = allKeys.value.map(item => item.key)
|
||||||
|
if (draggedIndex < 0 || draggedIndex >= keys.length || targetIndex < 0 || targetIndex >= keys.length) {
|
||||||
|
handleKeyDragEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedKey = keys[draggedIndex]
|
||||||
|
const targetKey = keys[targetIndex]
|
||||||
|
const draggedPriority = draggedKey.internal_priority ?? 0
|
||||||
|
const targetPriority = targetKey.internal_priority ?? 0
|
||||||
|
|
||||||
|
// 如果是同组内拖拽(同优先级),忽略操作
|
||||||
|
if (draggedPriority === targetPriority) {
|
||||||
|
handleKeyDragEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查目标 key 是否属于一个"组"(除了被拖拽的 key,还有其他 key 与目标同优先级)
|
||||||
|
// 组的定义:2 个及以上同优先级的 key
|
||||||
|
const keysAtTargetPriority = keys.filter(k =>
|
||||||
|
k.id !== draggedKey.id && (k.internal_priority ?? 0) === targetPriority
|
||||||
|
)
|
||||||
|
// 如果有 2 个及以上 key 在目标优先级(不含被拖拽的),说明目标在组内
|
||||||
|
const targetIsInGroup = keysAtTargetPriority.length >= 2
|
||||||
|
|
||||||
|
handleKeyDragEnd()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targetIsInGroup) {
|
||||||
|
// 目标在组内,被拖拽的 key 加入该组
|
||||||
|
await updateProviderKey(draggedKey.id, { internal_priority: targetPriority })
|
||||||
|
} else {
|
||||||
|
// 目标是单独的(或只有目标自己),交换优先级
|
||||||
|
await Promise.all([
|
||||||
|
updateProviderKey(draggedKey.id, { internal_priority: targetPriority }),
|
||||||
|
updateProviderKey(targetKey.id, { internal_priority: draggedPriority })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
showSuccess('优先级已更新')
|
||||||
|
await loadEndpoints()
|
||||||
|
emit('refresh')
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '更新优先级失败', '错误')
|
||||||
|
await loadEndpoints()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化探测时间
|
// 格式化探测时间
|
||||||
function formatProbeTime(probeTime: string): string {
|
function formatProbeTime(probeTime: string): string {
|
||||||
if (!probeTime) return '-'
|
if (!probeTime) return '-'
|
||||||
|
|||||||
@@ -168,6 +168,7 @@
|
|||||||
:provider-api-formats="providerApiFormats"
|
:provider-api-formats="providerApiFormats"
|
||||||
:models="models"
|
:models="models"
|
||||||
:editing-group="editingGroup"
|
:editing-group="editingGroup"
|
||||||
|
:preselected-model-id="preselectedModelId"
|
||||||
@saved="onDialogSaved"
|
@saved="onDialogSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -219,6 +220,7 @@ const deleteConfirmOpen = ref(false)
|
|||||||
const editingGroup = ref<AliasGroup | null>(null)
|
const editingGroup = ref<AliasGroup | null>(null)
|
||||||
const deletingGroup = ref<AliasGroup | null>(null)
|
const deletingGroup = ref<AliasGroup | null>(null)
|
||||||
const testingMapping = ref<string | null>(null)
|
const testingMapping = ref<string | null>(null)
|
||||||
|
const preselectedModelId = ref<string | null>(null)
|
||||||
|
|
||||||
// 列表展开状态
|
// 列表展开状态
|
||||||
const expandedAliasGroups = ref<Set<string>>(new Set())
|
const expandedAliasGroups = ref<Set<string>>(new Set())
|
||||||
@@ -311,12 +313,21 @@ function toggleAliasGroupExpand(groupKey: string) {
|
|||||||
// 打开添加对话框
|
// 打开添加对话框
|
||||||
function openAddDialog() {
|
function openAddDialog() {
|
||||||
editingGroup.value = null
|
editingGroup.value = null
|
||||||
|
preselectedModelId.value = null
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开添加对话框并预选模型(供外部调用)
|
||||||
|
function openAddDialogForModel(modelId: string) {
|
||||||
|
editingGroup.value = null
|
||||||
|
preselectedModelId.value = modelId
|
||||||
dialogOpen.value = true
|
dialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑分组
|
// 编辑分组
|
||||||
function editGroup(group: AliasGroup) {
|
function editGroup(group: AliasGroup) {
|
||||||
editingGroup.value = group
|
editingGroup.value = group
|
||||||
|
preselectedModelId.value = null
|
||||||
dialogOpen.value = true
|
dialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,8 +427,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 暴露给父组件,用于检测是否有弹窗打开
|
// 暴露给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
dialogOpen: computed(() => dialogOpen.value || deleteConfirmOpen.value)
|
dialogOpen: computed(() => dialogOpen.value || deleteConfirmOpen.value),
|
||||||
|
openAddDialogForModel
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,16 +34,13 @@
|
|||||||
<table class="w-full text-sm table-fixed">
|
<table class="w-full text-sm table-fixed">
|
||||||
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-left px-4 py-3 font-semibold w-[40%]">
|
<th class="text-left px-4 py-3 font-semibold w-[45%]">
|
||||||
模型
|
模型
|
||||||
</th>
|
</th>
|
||||||
<th class="text-left px-4 py-3 font-semibold w-[15%]">
|
<th class="text-left px-4 py-3 font-semibold w-[30%]">
|
||||||
能力
|
|
||||||
</th>
|
|
||||||
<th class="text-left px-4 py-3 font-semibold w-[25%]">
|
|
||||||
价格 ($/M)
|
价格 ($/M)
|
||||||
</th>
|
</th>
|
||||||
<th class="text-center px-4 py-3 font-semibold w-[20%]">
|
<th class="text-center px-4 py-3 font-semibold w-[25%]">
|
||||||
操作
|
操作
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -80,42 +77,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-top px-4 py-3">
|
|
||||||
<div
|
|
||||||
v-if="hasAnyCapability(model)"
|
|
||||||
class="grid grid-cols-3 gap-1 w-fit"
|
|
||||||
>
|
|
||||||
<Zap
|
|
||||||
v-if="model.effective_supports_streaming ?? model.supports_streaming"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="流式输出"
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
v-if="model.effective_supports_image_generation ?? model.supports_image_generation"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="图像生成"
|
|
||||||
/>
|
|
||||||
<Eye
|
|
||||||
v-if="model.effective_supports_vision ?? model.supports_vision"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="视觉理解"
|
|
||||||
/>
|
|
||||||
<Wrench
|
|
||||||
v-if="model.effective_supports_function_calling ?? model.supports_function_calling"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="工具调用"
|
|
||||||
/>
|
|
||||||
<Brain
|
|
||||||
v-if="model.effective_supports_extended_thinking ?? model.supports_extended_thinking"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="深度思考"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>—</span>
|
|
||||||
</td>
|
|
||||||
<td class="align-top px-4 py-3 text-xs whitespace-nowrap">
|
<td class="align-top px-4 py-3 text-xs whitespace-nowrap">
|
||||||
<div
|
<div
|
||||||
class="grid gap-1"
|
class="grid gap-1"
|
||||||
@@ -148,7 +109,7 @@
|
|||||||
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/次
|
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/次
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<!-- 无计费配置 -->
|
<!-- 无计<EFBFBD><EFBFBD>配置 -->
|
||||||
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
|
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
|
||||||
<span class="text-muted-foreground">—</span>
|
<span class="text-muted-foreground">—</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -156,6 +117,15 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="align-top px-4 py-3">
|
<td class="align-top px-4 py-3">
|
||||||
<div class="flex justify-center gap-1.5">
|
<div class="flex justify-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="添加映射"
|
||||||
|
@click="addMapping(model)"
|
||||||
|
>
|
||||||
|
<Link class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -209,7 +179,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image } from 'lucide-vue-next'
|
import { Box, Edit, Trash2, Layers, Power, Copy, Link } from 'lucide-vue-next'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
@@ -225,6 +195,7 @@ const emit = defineEmits<{
|
|||||||
'editModel': [model: Model]
|
'editModel': [model: Model]
|
||||||
'deleteModel': [model: Model]
|
'deleteModel': [model: Model]
|
||||||
'batchAssign': []
|
'batchAssign': []
|
||||||
|
'addMapping': [model: Model]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { error: showError, success: showSuccess } = useToast()
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
@@ -276,17 +247,6 @@ function formatPrice(price: number | null | undefined): string {
|
|||||||
return price.toFixed(4)
|
return price.toFixed(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查模型是否有任何能力
|
|
||||||
function hasAnyCapability(model: Model): boolean {
|
|
||||||
return !!(
|
|
||||||
(model.effective_supports_vision ?? model.supports_vision) ||
|
|
||||||
(model.effective_supports_function_calling ?? model.supports_function_calling) ||
|
|
||||||
(model.effective_supports_streaming ?? model.supports_streaming) ||
|
|
||||||
(model.effective_supports_extended_thinking ?? model.supports_extended_thinking) ||
|
|
||||||
(model.effective_supports_image_generation ?? model.supports_image_generation)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有按 Token 计费
|
// 检查是否有按 Token 计费
|
||||||
function hasTokenPricing(model: Model): boolean {
|
function hasTokenPricing(model: Model): boolean {
|
||||||
const inputPrice = model.effective_input_price
|
const inputPrice = model.effective_input_price
|
||||||
@@ -345,7 +305,7 @@ function getStatusTitle(model: Model): string {
|
|||||||
return '活跃但不可用'
|
return '活跃但不可用'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑模型
|
// <EFBFBD><EFBFBD>辑模型
|
||||||
function editModel(model: Model) {
|
function editModel(model: Model) {
|
||||||
emit('editModel', model)
|
emit('editModel', model)
|
||||||
}
|
}
|
||||||
@@ -355,6 +315,11 @@ function deleteModel(model: Model) {
|
|||||||
emit('deleteModel', model)
|
emit('deleteModel', model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加映射
|
||||||
|
function addMapping(model: Model) {
|
||||||
|
emit('addMapping', model)
|
||||||
|
}
|
||||||
|
|
||||||
// 打开批量关联对话框
|
// 打开批量关联对话框
|
||||||
function openBatchAssignDialog() {
|
function openBatchAssignDialog() {
|
||||||
emit('batchAssign')
|
emit('batchAssign')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
|
import { parseUpstreamModelError } from '@/utils/errorParser'
|
||||||
import type { UpstreamModel } from '@/api/endpoints/types'
|
import type { UpstreamModel } from '@/api/endpoints/types'
|
||||||
|
|
||||||
// 扩展类型,包含可能的额外字段
|
// 扩展类型,包含可能的额外字段
|
||||||
@@ -63,10 +64,14 @@ export function useUpstreamModelsCache() {
|
|||||||
})
|
})
|
||||||
return { models: response.data.models }
|
return { models: response.data.models }
|
||||||
} else {
|
} else {
|
||||||
return { models: [], error: response.data?.error || '获取上游模型失败' }
|
// 使用友好的错误解析
|
||||||
|
const rawError = response.data?.error || '获取上游模型失败'
|
||||||
|
return { models: [], error: parseUpstreamModelError(rawError) }
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return { models: [], error: err.response?.data?.detail || '获取上游模型失败' }
|
// 使用友好的错误解析
|
||||||
|
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
|
||||||
|
return { models: [], error: parseUpstreamModelError(rawError) }
|
||||||
} finally {
|
} finally {
|
||||||
loadingMap.value.set(providerId, false)
|
loadingMap.value.set(providerId, false)
|
||||||
pendingRequests.delete(providerId)
|
pendingRequests.delete(providerId)
|
||||||
|
|||||||
@@ -250,3 +250,115 @@ export function parseTestModelError(result: {
|
|||||||
|
|
||||||
return errorMsg
|
return errorMsg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析上游模型查询错误信息
|
||||||
|
* 将后端返回的原始错误信息(如 "HTTP 401: {json...}")转换为友好的错误提示
|
||||||
|
* @param error 错误字符串,格式可能是 "HTTP {status}: {json_body}" 或其他
|
||||||
|
* @returns 友好的错误信息
|
||||||
|
*/
|
||||||
|
export function parseUpstreamModelError(error: string): string {
|
||||||
|
if (!error) return '获取上游模型失败'
|
||||||
|
|
||||||
|
// 匹配 "HTTP {status}: {body}" 格式
|
||||||
|
const httpMatch = error.match(/^HTTP\s+(\d+):\s*(.*)$/s)
|
||||||
|
if (httpMatch) {
|
||||||
|
const status = parseInt(httpMatch[1], 10)
|
||||||
|
const body = httpMatch[2]
|
||||||
|
|
||||||
|
// 根据状态码生成友好消息
|
||||||
|
let friendlyMsg = ''
|
||||||
|
if (status === 401) {
|
||||||
|
friendlyMsg = '密钥无效或已过期'
|
||||||
|
} else if (status === 403) {
|
||||||
|
friendlyMsg = '密钥权限不足'
|
||||||
|
} else if (status === 404) {
|
||||||
|
friendlyMsg = '模型列表接口不存在'
|
||||||
|
} else if (status === 429) {
|
||||||
|
friendlyMsg = '请求频率过高,请稍后重试'
|
||||||
|
} else if (status >= 500) {
|
||||||
|
friendlyMsg = '上游服务暂时不可用'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从 JSON body 中提取更详细的错误信息
|
||||||
|
if (body) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body)
|
||||||
|
// 常见的错误格式: {error: {message: "..."}} 或 {error: "..."} 或 {message: "..."}
|
||||||
|
let detailMsg = ''
|
||||||
|
if (parsed.error?.message) {
|
||||||
|
detailMsg = parsed.error.message
|
||||||
|
} else if (typeof parsed.error === 'string') {
|
||||||
|
detailMsg = parsed.error
|
||||||
|
} else if (parsed.message) {
|
||||||
|
detailMsg = parsed.message
|
||||||
|
} else if (parsed.detail) {
|
||||||
|
detailMsg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果提取到了详细消息,用它来丰富友好消息
|
||||||
|
if (detailMsg) {
|
||||||
|
// 检查是否是 token/认证相关的错误
|
||||||
|
const lowerMsg = detailMsg.toLowerCase()
|
||||||
|
if (lowerMsg.includes('invalid token') || lowerMsg.includes('invalid api key')) {
|
||||||
|
return '密钥无效,请检查密钥是否正确'
|
||||||
|
}
|
||||||
|
if (lowerMsg.includes('expired')) {
|
||||||
|
return '密钥已过期,请更新密钥'
|
||||||
|
}
|
||||||
|
if (lowerMsg.includes('quota') || lowerMsg.includes('exceeded')) {
|
||||||
|
return '配额已用尽或超出限制'
|
||||||
|
}
|
||||||
|
if (lowerMsg.includes('rate limit')) {
|
||||||
|
return '请求频率过高,请稍后重试'
|
||||||
|
}
|
||||||
|
// 没有匹配特定关键词,但有详细信息,使用它作为补充
|
||||||
|
if (friendlyMsg) {
|
||||||
|
const truncated = detailMsg.length > 80 ? detailMsg.substring(0, 80) + '...' : detailMsg
|
||||||
|
return `${friendlyMsg}: ${truncated}`
|
||||||
|
}
|
||||||
|
// 没有友好消息,直接使用详细信息
|
||||||
|
const truncated = detailMsg.length > 100 ? detailMsg.substring(0, 100) + '...' : detailMsg
|
||||||
|
return truncated
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON 解析失败,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回友好消息,附加状态码
|
||||||
|
if (friendlyMsg) {
|
||||||
|
return friendlyMsg
|
||||||
|
}
|
||||||
|
return `请求失败 (HTTP ${status})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是请求错误
|
||||||
|
if (error.startsWith('Request error:')) {
|
||||||
|
const detail = error.replace('Request error:', '').trim()
|
||||||
|
if (detail.toLowerCase().includes('timeout')) {
|
||||||
|
return '请求超时,上游服务响应过慢'
|
||||||
|
}
|
||||||
|
if (detail.toLowerCase().includes('connection')) {
|
||||||
|
return '无法连接到上游服务'
|
||||||
|
}
|
||||||
|
return '网络请求失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是未知 API 格式
|
||||||
|
if (error.startsWith('Unknown API format:')) {
|
||||||
|
return '不支持的 API 格式'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果包含分号,可能是多个错误合并的,取第一个
|
||||||
|
if (error.includes('; ')) {
|
||||||
|
const firstError = error.split('; ')[0]
|
||||||
|
return parseUpstreamModelError(firstError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回原始错误(截断过长的部分)
|
||||||
|
if (error.length > 100) {
|
||||||
|
return error.substring(0, 100) + '...'
|
||||||
|
}
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user