mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-12 04:28:28 +08:00
refactor: 重构限流系统和健康监控,支持按 API 格式区分
- 将 adaptive_concurrency 重命名为 adaptive_rpm,从并发控制改为 RPM 控制 - 健康监控器支持按 API 格式独立管理健康度和熔断器状态 - 新增 model_permissions 模块,支持按格式配置允许的模型 - 重构前端提供商相关表单组件,新增 Collapsible UI 组件 - 新增数据库迁移脚本支持新的数据结构
This commit is contained in:
@@ -1,277 +1,219 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="internalOpen"
|
||||
:title="isEditMode ? '编辑 API 端点' : '添加 API 端点'"
|
||||
:description="isEditMode ? `修改 ${provider?.display_name} 的端点配置` : '为提供商添加新的 API 端点'"
|
||||
:icon="isEditMode ? SquarePen : Link"
|
||||
size="xl"
|
||||
title="端点管理"
|
||||
:description="`管理 ${provider?.name} 的 API 端点`"
|
||||
:icon="Settings"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<form
|
||||
class="space-y-6"
|
||||
@submit.prevent="handleSubmit()"
|
||||
>
|
||||
<!-- API 配置 -->
|
||||
<div class="space-y-4">
|
||||
<h3
|
||||
v-if="isEditMode"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
API 配置
|
||||
</h3>
|
||||
|
||||
<!-- API URL 和自定义路径 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="base_url">API URL *</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
v-model="form.base_url"
|
||||
placeholder="https://api.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="custom_path">自定义请求路径(可选)</Label>
|
||||
<Input
|
||||
id="custom_path"
|
||||
v-model="form.custom_path"
|
||||
:placeholder="isEditMode ? defaultPathPlaceholder : '留空使用各格式的默认路径'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 格式 -->
|
||||
<div class="space-y-4">
|
||||
<!-- 已有端点列表 -->
|
||||
<div
|
||||
v-if="localEndpoints.length > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label class="text-muted-foreground">已配置的端点</Label>
|
||||
<div class="space-y-2">
|
||||
<Label for="api_format">API 格式 *</Label>
|
||||
<template v-if="isEditMode">
|
||||
<Input
|
||||
id="api_format"
|
||||
v-model="form.api_format"
|
||||
disabled
|
||||
class="bg-muted"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
API 格式创建后不可修改
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-3 grid-flow-col grid-rows-2 gap-2">
|
||||
<label
|
||||
v-for="format in sortedApiFormats"
|
||||
:key="format.value"
|
||||
class="flex items-center gap-2 rounded-md border px-3 py-2 cursor-pointer transition-all text-sm"
|
||||
:class="selectedFormats.includes(format.value)
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="format.value"
|
||||
v-model="selectedFormats"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors"
|
||||
:class="selectedFormats.includes(format.value)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/30'"
|
||||
>
|
||||
<svg
|
||||
v-if="selectedFormats.includes(format.value)"
|
||||
class="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<div
|
||||
v-for="endpoint in localEndpoints"
|
||||
:key="endpoint.id"
|
||||
class="rounded-md border px-3 py-2"
|
||||
:class="{ 'opacity-50': !endpoint.is_active }"
|
||||
>
|
||||
<!-- 编辑模式 -->
|
||||
<template v-if="editingEndpointId === endpoint.id">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium w-24 shrink-0">{{ API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format }}</span>
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="保存"
|
||||
:disabled="savingEndpointId === endpoint.id"
|
||||
@click="saveEndpointUrl(endpoint)"
|
||||
>
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="取消"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">Base URL</Label>
|
||||
<Input
|
||||
v-model="editingUrl"
|
||||
class="h-8 text-sm"
|
||||
placeholder="https://api.example.com"
|
||||
@keyup.escape="cancelEdit"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">自定义路径 (可选)</Label>
|
||||
<Input
|
||||
v-model="editingPath"
|
||||
class="h-8 text-sm"
|
||||
:placeholder="editingDefaultPath || '留空使用默认路径'"
|
||||
@keyup.escape="cancelEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 查看模式 -->
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-24 shrink-0">
|
||||
<span class="text-sm font-medium">{{ API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-muted-foreground truncate block">
|
||||
{{ endpoint.base_url }}{{ endpoint.custom_path ? endpoint.custom_path : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑"
|
||||
@click="startEdit(endpoint)"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ format.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请求配置 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium">
|
||||
请求配置
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="timeout">超时(秒)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
v-model.number="form.timeout"
|
||||
type="number"
|
||||
placeholder="300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="max_retries">最大重试</Label>
|
||||
<Input
|
||||
id="max_retries"
|
||||
v-model.number="form.max_retries"
|
||||
type="number"
|
||||
placeholder="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="max_concurrent">最大并发</Label>
|
||||
<Input
|
||||
id="max_concurrent"
|
||||
:model-value="form.max_concurrent ?? ''"
|
||||
type="number"
|
||||
placeholder="无限制"
|
||||
@update:model-value="(v) => form.max_concurrent = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="rate_limit">速率限制(/分钟)</Label>
|
||||
<Input
|
||||
id="rate_limit"
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
placeholder="无限制"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v)"
|
||||
/>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:title="endpoint.is_active ? '停用' : '启用'"
|
||||
:disabled="togglingEndpointId === endpoint.id"
|
||||
@click="handleToggleEndpoint(endpoint)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-destructive hover:text-destructive"
|
||||
title="删除"
|
||||
:disabled="deletingEndpointId === endpoint.id"
|
||||
@click="handleDeleteEndpoint(endpoint)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理配置 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">
|
||||
代理配置
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch v-model="proxyEnabled" />
|
||||
<span class="text-sm text-muted-foreground">启用代理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="proxyEnabled"
|
||||
class="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="proxy_url">代理 URL *</Label>
|
||||
<Input
|
||||
id="proxy_url"
|
||||
v-model="form.proxy_url"
|
||||
placeholder="http://host:port 或 socks5://host:port"
|
||||
required
|
||||
:class="proxyUrlError ? 'border-red-500' : ''"
|
||||
/>
|
||||
<p
|
||||
v-if="proxyUrlError"
|
||||
class="text-xs text-red-500"
|
||||
<!-- 添加新端点 -->
|
||||
<div
|
||||
v-if="availableFormats.length > 0"
|
||||
class="space-y-3 pt-3 border-t"
|
||||
>
|
||||
<Label class="text-muted-foreground">添加新端点</Label>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="w-32 shrink-0 space-y-1.5">
|
||||
<Label class="text-xs">API 格式</Label>
|
||||
<Select
|
||||
v-model="newEndpoint.api_format"
|
||||
v-model:open="formatSelectOpen"
|
||||
>
|
||||
{{ proxyUrlError }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
支持 HTTP、HTTPS、SOCKS5 代理
|
||||
</p>
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue placeholder="选择格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="format in availableFormats"
|
||||
:key="format.value"
|
||||
:value="format.value"
|
||||
>
|
||||
{{ format.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="proxy_user">用户名(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_user_${formId}`"
|
||||
v-model="form.proxy_username"
|
||||
:name="`proxy_user_${formId}`"
|
||||
placeholder="代理认证用户名"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label :for="`proxy_pass_${formId}`">密码(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_pass_${formId}`"
|
||||
v-model="form.proxy_password"
|
||||
:name="`proxy_pass_${formId}`"
|
||||
type="text"
|
||||
:placeholder="passwordPlaceholder"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
:style="{ '-webkit-text-security': 'disc', 'text-security': 'disc' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<Label class="text-xs">Base URL</Label>
|
||||
<Input
|
||||
v-model="newEndpoint.base_url"
|
||||
placeholder="https://api.example.com"
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-40 shrink-0 space-y-1.5">
|
||||
<Label class="text-xs">自定义路径</Label>
|
||||
<Input
|
||||
v-model="newEndpoint.custom_path"
|
||||
:placeholder="newEndpointDefaultPath || '可选'"
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 shrink-0"
|
||||
:disabled="!newEndpoint.api_format || !newEndpoint.base_url || addingEndpoint"
|
||||
@click="handleAddEndpoint"
|
||||
>
|
||||
{{ addingEndpoint ? '添加中...' : '添加' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="localEndpoints.length === 0 && availableFormats.length === 0"
|
||||
class="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
<p>所有 API 格式都已配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="handleCancel"
|
||||
@click="handleClose"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="loading || !form.base_url || (!isEditMode && selectedFormats.length === 0)"
|
||||
@click="handleSubmit()"
|
||||
>
|
||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : `创建 ${selectedFormats.length} 个端点`) }}
|
||||
关闭
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 确认清空凭据对话框 -->
|
||||
<AlertDialog
|
||||
v-model="showClearCredentialsDialog"
|
||||
title="清空代理凭据"
|
||||
description="代理 URL 为空,但用户名和密码仍有值。是否清空这些凭据并继续保存?"
|
||||
type="warning"
|
||||
confirm-text="清空并保存"
|
||||
cancel-text="返回编辑"
|
||||
@confirm="confirmClearCredentials"
|
||||
@cancel="showClearCredentialsDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import { Link, SquarePen } from 'lucide-vue-next'
|
||||
import { Settings, Edit, Trash2, Check, X, Power } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
createEndpoint,
|
||||
updateEndpoint,
|
||||
deleteEndpoint,
|
||||
API_FORMAT_LABELS,
|
||||
type ProviderEndpoint,
|
||||
type ProviderWithEndpointsSummary
|
||||
} from '@/api/endpoints'
|
||||
@@ -280,7 +222,7 @@ import { adminApi } from '@/api/admin'
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
provider: ProviderWithEndpointsSummary | null
|
||||
endpoint?: ProviderEndpoint | null // 编辑模式时传入
|
||||
endpoints?: ProviderEndpoint[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -290,308 +232,184 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const proxyEnabled = ref(false)
|
||||
const showClearCredentialsDialog = ref(false) // 确认清空凭据对话框
|
||||
|
||||
// 生成随机 ID 防止浏览器自动填充
|
||||
const formId = Math.random().toString(36).substring(2, 10)
|
||||
// 状态
|
||||
const addingEndpoint = ref(false)
|
||||
const editingEndpointId = ref<string | null>(null)
|
||||
const editingUrl = ref('')
|
||||
const editingPath = ref('')
|
||||
const savingEndpointId = ref<string | null>(null)
|
||||
const deletingEndpointId = ref<string | null>(null)
|
||||
const togglingEndpointId = ref<string | null>(null)
|
||||
const formatSelectOpen = ref(false)
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
// 新端点表单
|
||||
const newEndpoint = ref({
|
||||
api_format: '',
|
||||
base_url: '',
|
||||
custom_path: '',
|
||||
timeout: 300,
|
||||
max_retries: 2,
|
||||
max_concurrent: undefined as number | undefined,
|
||||
rate_limit: undefined as number | undefined,
|
||||
is_active: true,
|
||||
// 代理配置
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
})
|
||||
|
||||
// 选中的 API 格式(多选)
|
||||
const selectedFormats = ref<string[]>([])
|
||||
|
||||
// API 格式列表
|
||||
const apiFormats = ref<Array<{ value: string; label: string; default_path: string; aliases: string[] }>>([])
|
||||
const apiFormats = ref<Array<{ value: string; label: string; default_path: string }>>([])
|
||||
|
||||
// 排序后的 API 格式:按列排列,每列是基础格式+CLI格式
|
||||
const sortedApiFormats = computed(() => {
|
||||
const baseFormats = apiFormats.value.filter(f => !f.value.endsWith('_cli'))
|
||||
const cliFormats = apiFormats.value.filter(f => f.value.endsWith('_cli'))
|
||||
// 交错排列:base1, cli1, base2, cli2, base3, cli3
|
||||
const result: typeof apiFormats.value = []
|
||||
for (let i = 0; i < baseFormats.length; i++) {
|
||||
result.push(baseFormats[i])
|
||||
const cliFormat = cliFormats.find(f => f.value === baseFormats[i].value + '_cli')
|
||||
if (cliFormat) {
|
||||
result.push(cliFormat)
|
||||
}
|
||||
}
|
||||
return result
|
||||
// 本地端点列表
|
||||
const localEndpoints = ref<ProviderEndpoint[]>([])
|
||||
|
||||
// 可用的格式(未添加的)
|
||||
const availableFormats = computed(() => {
|
||||
const existingFormats = localEndpoints.value.map(e => e.api_format)
|
||||
return apiFormats.value.filter(f => !existingFormats.includes(f.value))
|
||||
})
|
||||
|
||||
// 加载API格式列表
|
||||
// 获取指定 API 格式的默认路径
|
||||
function getDefaultPath(apiFormat: string): string {
|
||||
const format = apiFormats.value.find(f => f.value === apiFormat)
|
||||
return format?.default_path || ''
|
||||
}
|
||||
|
||||
// 当前编辑端点的默认路径
|
||||
const editingDefaultPath = computed(() => {
|
||||
const endpoint = localEndpoints.value.find(e => e.id === editingEndpointId.value)
|
||||
return endpoint ? getDefaultPath(endpoint.api_format) : ''
|
||||
})
|
||||
|
||||
// 新端点选择的格式的默认路径
|
||||
const newEndpointDefaultPath = computed(() => {
|
||||
return getDefaultPath(newEndpoint.value.api_format)
|
||||
})
|
||||
|
||||
// 加载 API 格式列表
|
||||
const loadApiFormats = async () => {
|
||||
try {
|
||||
const response = await adminApi.getApiFormats()
|
||||
apiFormats.value = response.formats
|
||||
} catch (error) {
|
||||
log.error('加载API格式失败:', error)
|
||||
if (!isEditMode.value) {
|
||||
showError('加载API格式失败', '错误')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据选择的 API 格式计算默认路径
|
||||
const defaultPath = computed(() => {
|
||||
const format = apiFormats.value.find(f => f.value === form.value.api_format)
|
||||
return format?.default_path || '/'
|
||||
})
|
||||
|
||||
// 动态 placeholder
|
||||
const defaultPathPlaceholder = computed(() => {
|
||||
return defaultPath.value
|
||||
})
|
||||
|
||||
// 检查是否有已保存的密码(后端返回 *** 表示有密码)
|
||||
const hasExistingPassword = computed(() => {
|
||||
if (!props.endpoint?.proxy) return false
|
||||
const proxy = props.endpoint.proxy as { password?: string }
|
||||
return proxy?.password === MASKED_PASSWORD
|
||||
})
|
||||
|
||||
// 密码输入框的 placeholder
|
||||
const passwordPlaceholder = computed(() => {
|
||||
if (hasExistingPassword.value) {
|
||||
return '已保存密码,留空保持不变'
|
||||
}
|
||||
return '代理认证密码'
|
||||
})
|
||||
|
||||
// 代理 URL 验证
|
||||
const proxyUrlError = computed(() => {
|
||||
// 只有启用代理且填写了 URL 时才验证
|
||||
if (!proxyEnabled.value || !form.value.proxy_url) {
|
||||
return ''
|
||||
}
|
||||
const url = form.value.proxy_url.trim()
|
||||
|
||||
// 检查禁止的特殊字符
|
||||
if (/[\n\r]/.test(url)) {
|
||||
return '代理 URL 包含非法字符'
|
||||
}
|
||||
|
||||
// 验证协议(不支持 SOCKS4)
|
||||
if (!/^(http|https|socks5):\/\//i.test(url)) {
|
||||
return '代理 URL 必须以 http://, https:// 或 socks5:// 开头'
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (!parsed.host) {
|
||||
return '代理 URL 必须包含有效的 host'
|
||||
}
|
||||
// 禁止 URL 中内嵌认证信息
|
||||
if (parsed.username || parsed.password) {
|
||||
return '请勿在 URL 中包含用户名和密码,请使用独立的认证字段'
|
||||
}
|
||||
} catch {
|
||||
return '代理 URL 格式无效'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 组件挂载时加载API格式
|
||||
onMounted(() => {
|
||||
loadApiFormats()
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
api_format: '',
|
||||
base_url: '',
|
||||
custom_path: '',
|
||||
timeout: 300,
|
||||
max_retries: 2,
|
||||
max_concurrent: undefined,
|
||||
rate_limit: undefined,
|
||||
is_active: true,
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
// 监听 props 变化
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
localEndpoints.value = [...(props.endpoints || [])]
|
||||
// 重置编辑状态
|
||||
editingEndpointId.value = null
|
||||
editingUrl.value = ''
|
||||
editingPath.value = ''
|
||||
} else {
|
||||
// 关闭对话框时完全清空新端点表单
|
||||
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
|
||||
}
|
||||
selectedFormats.value = []
|
||||
proxyEnabled.value = false
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.endpoints, (endpoints) => {
|
||||
if (props.modelValue) {
|
||||
localEndpoints.value = [...(endpoints || [])]
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 开始编辑
|
||||
function startEdit(endpoint: ProviderEndpoint) {
|
||||
editingEndpointId.value = endpoint.id
|
||||
editingUrl.value = endpoint.base_url
|
||||
editingPath.value = endpoint.custom_path || ''
|
||||
}
|
||||
|
||||
// 原始密码占位符(后端返回的脱敏标记)
|
||||
const MASKED_PASSWORD = '***'
|
||||
|
||||
// 加载端点数据(编辑模式)
|
||||
function loadEndpointData() {
|
||||
if (!props.endpoint) return
|
||||
|
||||
const proxy = props.endpoint.proxy as { url?: string; username?: string; password?: string; enabled?: boolean } | null
|
||||
|
||||
form.value = {
|
||||
api_format: props.endpoint.api_format,
|
||||
base_url: props.endpoint.base_url,
|
||||
custom_path: props.endpoint.custom_path || '',
|
||||
timeout: props.endpoint.timeout,
|
||||
max_retries: props.endpoint.max_retries,
|
||||
max_concurrent: props.endpoint.max_concurrent || undefined,
|
||||
rate_limit: props.endpoint.rate_limit || undefined,
|
||||
is_active: props.endpoint.is_active,
|
||||
proxy_url: proxy?.url || '',
|
||||
proxy_username: proxy?.username || '',
|
||||
// 如果密码是脱敏标记,显示为空(让用户知道有密码但看不到)
|
||||
proxy_password: proxy?.password === MASKED_PASSWORD ? '' : (proxy?.password || ''),
|
||||
}
|
||||
|
||||
// 根据 enabled 字段或 url 存在判断是否启用代理
|
||||
proxyEnabled.value = proxy?.enabled ?? !!proxy?.url
|
||||
// 取消编辑
|
||||
function cancelEdit() {
|
||||
editingEndpointId.value = null
|
||||
editingUrl.value = ''
|
||||
editingPath.value = ''
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
isOpen: () => props.modelValue,
|
||||
entity: () => props.endpoint,
|
||||
isLoading: loading,
|
||||
onClose: () => emit('update:modelValue', false),
|
||||
loadData: loadEndpointData,
|
||||
resetForm,
|
||||
})
|
||||
// 保存端点
|
||||
async function saveEndpointUrl(endpoint: ProviderEndpoint) {
|
||||
if (!editingUrl.value) return
|
||||
|
||||
// 构建代理配置
|
||||
// - 有 URL 时始终保存配置,通过 enabled 字段控制是否启用
|
||||
// - 无 URL 时返回 null
|
||||
function buildProxyConfig(): { url: string; username?: string; password?: string; enabled: boolean } | null {
|
||||
if (!form.value.proxy_url) {
|
||||
// 没填 URL,无代理配置
|
||||
return null
|
||||
}
|
||||
return {
|
||||
url: form.value.proxy_url,
|
||||
username: form.value.proxy_username || undefined,
|
||||
password: form.value.proxy_password || undefined,
|
||||
enabled: proxyEnabled.value, // 开关状态决定是否启用
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (skipCredentialCheck = false) => {
|
||||
if (!props.provider && !props.endpoint) return
|
||||
|
||||
// 只在开关开启且填写了 URL 时验证
|
||||
if (proxyEnabled.value && form.value.proxy_url && proxyUrlError.value) {
|
||||
showError(proxyUrlError.value, '代理配置错误')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查:开关开启但没有 URL,却有用户名或密码
|
||||
const hasOrphanedCredentials = proxyEnabled.value
|
||||
&& !form.value.proxy_url
|
||||
&& (form.value.proxy_username || form.value.proxy_password)
|
||||
|
||||
if (hasOrphanedCredentials && !skipCredentialCheck) {
|
||||
// 弹出确认对话框
|
||||
showClearCredentialsDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
let successCount = 0
|
||||
|
||||
savingEndpointId.value = endpoint.id
|
||||
try {
|
||||
const proxyConfig = buildProxyConfig()
|
||||
|
||||
if (isEditMode.value && props.endpoint) {
|
||||
// 更新端点
|
||||
await updateEndpoint(props.endpoint.id, {
|
||||
base_url: form.value.base_url,
|
||||
custom_path: form.value.custom_path || undefined,
|
||||
timeout: form.value.timeout,
|
||||
max_retries: form.value.max_retries,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
is_active: form.value.is_active,
|
||||
proxy: proxyConfig,
|
||||
})
|
||||
|
||||
success('端点已更新', '保存成功')
|
||||
emit('endpointUpdated')
|
||||
emit('update:modelValue', false)
|
||||
} else if (props.provider) {
|
||||
// 批量创建端点 - 使用并发请求提升性能
|
||||
const results = await Promise.allSettled(
|
||||
selectedFormats.value.map(apiFormat =>
|
||||
createEndpoint(props.provider!.id, {
|
||||
provider_id: props.provider!.id,
|
||||
api_format: apiFormat,
|
||||
base_url: form.value.base_url,
|
||||
custom_path: form.value.custom_path || undefined,
|
||||
timeout: form.value.timeout,
|
||||
max_retries: form.value.max_retries,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
is_active: form.value.is_active,
|
||||
proxy: proxyConfig,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 统计结果
|
||||
const errors: string[] = []
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++
|
||||
} else {
|
||||
const apiFormat = selectedFormats.value[index]
|
||||
const formatLabel = apiFormats.value.find((f: any) => f.value === apiFormat)?.label || apiFormat
|
||||
const errorMsg = result.reason?.response?.data?.detail || '创建失败'
|
||||
errors.push(`${formatLabel}: ${errorMsg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const failCount = errors.length
|
||||
|
||||
// 显示结果
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
success(`成功创建 ${successCount} 个端点`, '创建成功')
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
showError(`${failCount} 个端点创建失败:\n${errors.join('\n')}`, `${successCount} 个成功,${failCount} 个失败`)
|
||||
} else {
|
||||
showError(errors.join('\n') || '创建端点失败', '创建失败')
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
emit('endpointCreated')
|
||||
resetForm()
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
await updateEndpoint(endpoint.id, {
|
||||
base_url: editingUrl.value,
|
||||
custom_path: editingPath.value || null, // 空字符串时传 null 清空
|
||||
})
|
||||
success('端点已更新')
|
||||
emit('endpointUpdated')
|
||||
cancelEdit()
|
||||
} catch (error: any) {
|
||||
const action = isEditMode.value ? '更新' : '创建'
|
||||
showError(error.response?.data?.detail || `${action}端点失败`, '错误')
|
||||
showError(error.response?.data?.detail || '更新失败', '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
savingEndpointId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 确认清空凭据并继续保存
|
||||
const confirmClearCredentials = () => {
|
||||
form.value.proxy_username = ''
|
||||
form.value.proxy_password = ''
|
||||
showClearCredentialsDialog.value = false
|
||||
handleSubmit(true) // 跳过凭据检查,直接提交
|
||||
// 添加端点
|
||||
async function handleAddEndpoint() {
|
||||
if (!props.provider || !newEndpoint.value.api_format || !newEndpoint.value.base_url) return
|
||||
|
||||
addingEndpoint.value = true
|
||||
try {
|
||||
await createEndpoint(props.provider.id, {
|
||||
provider_id: props.provider.id,
|
||||
api_format: newEndpoint.value.api_format,
|
||||
base_url: newEndpoint.value.base_url,
|
||||
custom_path: newEndpoint.value.custom_path || undefined,
|
||||
is_active: true,
|
||||
})
|
||||
success(`已添加 ${API_FORMAT_LABELS[newEndpoint.value.api_format] || newEndpoint.value.api_format} 端点`)
|
||||
// 重置表单,保留 URL
|
||||
const url = newEndpoint.value.base_url
|
||||
newEndpoint.value = { api_format: '', base_url: url, custom_path: '' }
|
||||
emit('endpointCreated')
|
||||
} catch (error: any) {
|
||||
showError(error.response?.data?.detail || '添加失败', '错误')
|
||||
} finally {
|
||||
addingEndpoint.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换端点启用状态
|
||||
async function handleToggleEndpoint(endpoint: ProviderEndpoint) {
|
||||
togglingEndpointId.value = endpoint.id
|
||||
try {
|
||||
const newStatus = !endpoint.is_active
|
||||
await updateEndpoint(endpoint.id, { is_active: newStatus })
|
||||
success(newStatus ? '端点已启用' : '端点已停用')
|
||||
emit('endpointUpdated')
|
||||
} catch (error: any) {
|
||||
showError(error.response?.data?.detail || '操作失败', '错误')
|
||||
} finally {
|
||||
togglingEndpointId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 删除端点
|
||||
async function handleDeleteEndpoint(endpoint: ProviderEndpoint) {
|
||||
deletingEndpointId.value = endpoint.id
|
||||
try {
|
||||
await deleteEndpoint(endpoint.id)
|
||||
success(`已删除 ${API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format} 端点`)
|
||||
emit('endpointUpdated')
|
||||
} catch (error: any) {
|
||||
showError(error.response?.data?.detail || '删除失败', '错误')
|
||||
} finally {
|
||||
deletingEndpointId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
function handleDialogUpdate(value: boolean) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,165 +1,179 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
title="配置允许的模型"
|
||||
description="选择该 API Key 允许访问的模型,留空则允许访问所有模型"
|
||||
:icon="Settings2"
|
||||
title="获取上游模型"
|
||||
:description="`使用密钥 ${props.apiKey?.name || props.apiKey?.api_key_masked || ''} 从上游获取模型列表。导入的模型需要关联全局模型后才能参与路由。`"
|
||||
:icon="Layers"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-4 py-2">
|
||||
<!-- 已选模型展示 -->
|
||||
<div
|
||||
v-if="selectedModels.length > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">
|
||||
已选模型 ({{ selectedModels.length }})
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 text-xs hover:text-destructive"
|
||||
@click="clearModels"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 p-2 bg-muted/20 rounded-lg border border-border/40 min-h-[40px]">
|
||||
<Badge
|
||||
v-for="modelName in selectedModels"
|
||||
:key="modelName"
|
||||
variant="secondary"
|
||||
class="text-[11px] px-2 py-0.5 bg-background border-border/60 shadow-sm text-foreground dark:text-white"
|
||||
>
|
||||
{{ getModelLabel(modelName) }}
|
||||
<button
|
||||
class="ml-0.5 hover:text-destructive focus:outline-none text-foreground dark:text-white"
|
||||
@click.stop="toggleModel(modelName, false)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
<!-- 操作区域 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span v-if="!hasQueried">点击获取按钮查询上游可用模型</span>
|
||||
<span v-else-if="upstreamModels.length > 0">
|
||||
共 {{ upstreamModels.length }} 个模型,已选 {{ selectedModels.length }} 个
|
||||
</span>
|
||||
<span v-else>未找到可用模型</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@click="fetchUpstreamModels"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-3.5 h-3.5 mr-1.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
{{ hasQueried ? '刷新' : '获取模型' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表区域 -->
|
||||
<div class="space-y-2">
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center py-12 space-y-3"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-2 border-primary/20 border-t-primary" />
|
||||
<span class="text-xs text-muted-foreground">正在从上游获取模型列表...</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else-if="errorMessage"
|
||||
class="flex flex-col items-center justify-center py-12 text-destructive border border-dashed border-destructive/30 rounded-lg bg-destructive/5"
|
||||
>
|
||||
<AlertCircle class="w-10 h-10 mb-2 opacity-50" />
|
||||
<span class="text-sm text-center px-4">{{ errorMessage }}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-3"
|
||||
@click="fetchUpstreamModels"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 未查询状态 -->
|
||||
<div
|
||||
v-else-if="!hasQueried"
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground border border-dashed rounded-lg bg-muted/10"
|
||||
>
|
||||
<Layers class="w-10 h-10 mb-2 opacity-20" />
|
||||
<span class="text-sm">点击上方按钮获取模型列表</span>
|
||||
</div>
|
||||
|
||||
<!-- 无模型 -->
|
||||
<div
|
||||
v-else-if="upstreamModels.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground border border-dashed rounded-lg bg-muted/10"
|
||||
>
|
||||
<Box class="w-10 h-10 mb-2 opacity-20" />
|
||||
<span class="text-sm">上游 API 未返回可用模型</span>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<div v-else class="space-y-2">
|
||||
<!-- 全选/取消 -->
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">
|
||||
可选模型列表
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="isAllSelected"
|
||||
:indeterminate="isPartiallySelected"
|
||||
@update:checked="toggleSelectAll"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loadingModels && availableModels.length > 0"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
>
|
||||
共 {{ availableModels.length }} 个模型
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ newModelsCount }} 个新模型(不在本地)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loadingModels"
|
||||
class="flex flex-col items-center justify-center py-12 space-y-3"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-2 border-primary/20 border-t-primary" />
|
||||
<span class="text-xs text-muted-foreground">正在加载模型列表...</span>
|
||||
</div>
|
||||
|
||||
<!-- 无模型 -->
|
||||
<div
|
||||
v-else-if="availableModels.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground border border-dashed rounded-lg bg-muted/10"
|
||||
>
|
||||
<Box class="w-10 h-10 mb-2 opacity-20" />
|
||||
<span class="text-sm">暂无可选模型</span>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<div
|
||||
v-else
|
||||
class="max-h-[320px] overflow-y-auto pr-1 space-y-1.5 custom-scrollbar"
|
||||
>
|
||||
<div class="max-h-[320px] overflow-y-auto pr-1 space-y-1 custom-scrollbar">
|
||||
<div
|
||||
v-for="model in availableModels"
|
||||
:key="model.global_model_name"
|
||||
v-for="model in upstreamModels"
|
||||
:key="`${model.id}:${model.api_format || ''}`"
|
||||
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.global_model_name)
|
||||
selectedModels.includes(model.id)
|
||||
? 'border-primary/40 bg-primary/5 shadow-sm'
|
||||
: 'border-border/40 bg-background hover:border-primary/20 hover:bg-muted/30'
|
||||
]"
|
||||
@click="toggleModel(model.global_model_name, !selectedModels.includes(model.global_model_name))"
|
||||
@click="toggleModel(model.id)"
|
||||
>
|
||||
<!-- Checkbox -->
|
||||
<Checkbox
|
||||
:checked="selectedModels.includes(model.global_model_name)"
|
||||
:checked="selectedModels.includes(model.id)"
|
||||
class="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
@click.stop
|
||||
@update:checked="checked => toggleModel(model.global_model_name, checked)"
|
||||
@update:checked="checked => toggleModel(model.id, checked)"
|
||||
/>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium truncate text-foreground/90">{{ model.display_name }}</span>
|
||||
<span
|
||||
v-if="hasPricing(model)"
|
||||
class="text-[10px] font-mono text-muted-foreground/80 bg-muted/30 px-1.5 py-0.5 rounded border border-border/30 shrink-0"
|
||||
>
|
||||
{{ formatPricingShort(model) }}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium truncate text-foreground/90">
|
||||
{{ model.display_name || model.id }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="model.api_format"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ API_FORMAT_LABELS[model.api_format] || model.api_format }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="isModelExisting(model.id)"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
已存在
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-[11px] text-muted-foreground/60 font-mono truncate mt-0.5">
|
||||
{{ model.global_model_name }}
|
||||
{{ model.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
title="测试模型连接"
|
||||
:disabled="testingModelName === model.global_model_name"
|
||||
@click.stop="testModelConnection(model)"
|
||||
<div
|
||||
v-if="model.owned_by"
|
||||
class="text-[10px] text-muted-foreground/50 shrink-0"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testingModelName === model.global_model_name"
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</Button>
|
||||
{{ model.owned_by }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 w-full pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-9"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="saving"
|
||||
class="h-9 min-w-[80px]"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Loader2
|
||||
v-if="saving"
|
||||
class="w-3.5 h-3.5 mr-1.5 animate-spin"
|
||||
/>
|
||||
{{ saving ? '保存中' : '保存配置' }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-between w-full pt-2">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
<span v-if="selectedModels.length > 0 && newSelectedCount > 0">
|
||||
将导入 {{ newSelectedCount }} 个新模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-9"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="importing || selectedModels.length === 0 || newSelectedCount === 0"
|
||||
class="h-9 min-w-[100px]"
|
||||
@click="handleImport"
|
||||
>
|
||||
<Loader2
|
||||
v-if="importing"
|
||||
class="w-3.5 h-3.5 mr-1.5 animate-spin"
|
||||
/>
|
||||
{{ importing ? '导入中' : `导入 ${newSelectedCount} 个模型` }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -167,19 +181,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Box, Loader2, Settings2, Play } from 'lucide-vue-next'
|
||||
import { Box, Layers, Loader2, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import {
|
||||
updateEndpointKey,
|
||||
getProviderAvailableSourceModels,
|
||||
testModel,
|
||||
importModelsFromUpstream,
|
||||
getProviderModels,
|
||||
type EndpointAPIKey,
|
||||
type ProviderAvailableSourceModel
|
||||
type UpstreamModel,
|
||||
API_FORMAT_LABELS,
|
||||
} from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -196,130 +210,116 @@ const emit = defineEmits<{
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const hasQueried = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
const selectedModels = ref<string[]>([])
|
||||
const initialModels = ref<string[]>([])
|
||||
const testingModelName = ref<string | null>(null)
|
||||
const existingModelIds = ref<Set<string>>(new Set())
|
||||
|
||||
// 计算属性
|
||||
const isAllSelected = computed(() =>
|
||||
upstreamModels.value.length > 0 &&
|
||||
selectedModels.value.length === upstreamModels.value.length
|
||||
)
|
||||
|
||||
const isPartiallySelected = computed(() =>
|
||||
selectedModels.value.length > 0 &&
|
||||
selectedModels.value.length < upstreamModels.value.length
|
||||
)
|
||||
|
||||
const newModelsCount = computed(() =>
|
||||
upstreamModels.value.filter(m => !existingModelIds.value.has(m.id)).length
|
||||
)
|
||||
|
||||
const newSelectedCount = computed(() =>
|
||||
selectedModels.value.filter(id => !existingModelIds.value.has(id)).length
|
||||
)
|
||||
|
||||
// 检查模型是否已存在
|
||||
function isModelExisting(modelId: string): boolean {
|
||||
return existingModelIds.value.has(modelId)
|
||||
}
|
||||
|
||||
// 监听对话框打开
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
loadData()
|
||||
resetState()
|
||||
loadExistingModels()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
// 初始化已选模型
|
||||
if (props.apiKey?.allowed_models) {
|
||||
selectedModels.value = [...props.apiKey.allowed_models]
|
||||
initialModels.value = [...props.apiKey.allowed_models]
|
||||
} else {
|
||||
selectedModels.value = []
|
||||
initialModels.value = []
|
||||
}
|
||||
|
||||
// 加载可选模型
|
||||
if (props.providerId) {
|
||||
await loadAvailableModels()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableModels() {
|
||||
if (!props.providerId) return
|
||||
try {
|
||||
loadingModels.value = true
|
||||
const response = await getProviderAvailableSourceModels(props.providerId)
|
||||
availableModels.value = response.models
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '加载模型列表失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const modelLabelMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
availableModels.value.forEach(model => {
|
||||
map.set(model.global_model_name, model.display_name || model.global_model_name)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
function getModelLabel(modelName: string): string {
|
||||
return modelLabelMap.value.get(modelName) ?? modelName
|
||||
}
|
||||
|
||||
function hasPricing(model: ProviderAvailableSourceModel): boolean {
|
||||
const input = model.price.input_price_per_1m ?? 0
|
||||
const output = model.price.output_price_per_1m ?? 0
|
||||
return input > 0 || output > 0
|
||||
}
|
||||
|
||||
function formatPricingShort(model: ProviderAvailableSourceModel): string {
|
||||
const input = model.price.input_price_per_1m ?? 0
|
||||
const output = model.price.output_price_per_1m ?? 0
|
||||
if (input > 0 || output > 0) {
|
||||
return `$${formatPrice(input)}/$${formatPrice(output)}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatPrice(value?: number | null): string {
|
||||
if (value === undefined || value === null || value === 0) return '0'
|
||||
if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function toggleModel(modelName: string, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!selectedModels.value.includes(modelName)) {
|
||||
selectedModels.value = [...selectedModels.value, modelName]
|
||||
}
|
||||
} else {
|
||||
selectedModels.value = selectedModels.value.filter(name => name !== modelName)
|
||||
}
|
||||
}
|
||||
|
||||
function clearModels() {
|
||||
function resetState() {
|
||||
hasQueried.value = false
|
||||
errorMessage.value = ''
|
||||
upstreamModels.value = []
|
||||
selectedModels.value = []
|
||||
}
|
||||
|
||||
// 测试模型连接
|
||||
async function testModelConnection(model: ProviderAvailableSourceModel) {
|
||||
if (!props.providerId || !props.apiKey || testingModelName.value) return
|
||||
|
||||
testingModelName.value = model.global_model_name
|
||||
// 加载已存在的模型列表
|
||||
async function loadExistingModels() {
|
||||
if (!props.providerId) return
|
||||
try {
|
||||
const result = await testModel({
|
||||
provider_id: props.providerId,
|
||||
model_name: model.provider_model_name,
|
||||
api_key_id: props.apiKey.id,
|
||||
message: "hello"
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
success(`模型 "${model.display_name}" 测试成功`)
|
||||
} else {
|
||||
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||
showError(`模型测试失败: ${errorMsg}`)
|
||||
} finally {
|
||||
testingModelName.value = null
|
||||
const models = await getProviderModels(props.providerId)
|
||||
existingModelIds.value = new Set(
|
||||
models.map((m: { provider_model_name: string }) => m.provider_model_name)
|
||||
)
|
||||
} catch {
|
||||
existingModelIds.value = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function areArraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort()
|
||||
const sortedB = [...b].sort()
|
||||
return sortedA.every((value, index) => value === sortedB[index])
|
||||
// 获取上游模型
|
||||
async function fetchUpstreamModels() {
|
||||
if (!props.providerId || !props.apiKey) return
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
|
||||
|
||||
if (response.success && response.data?.models) {
|
||||
upstreamModels.value = response.data.models
|
||||
// 默认选中所有新模型
|
||||
selectedModels.value = response.data.models
|
||||
.filter((m: UpstreamModel) => !existingModelIds.value.has(m.id))
|
||||
.map((m: UpstreamModel) => m.id)
|
||||
hasQueried.value = true
|
||||
// 如果有部分失败,显示警告提示
|
||||
if (response.data.error) {
|
||||
showError(`部分格式获取失败: ${response.data.error}`, '警告')
|
||||
}
|
||||
} else {
|
||||
errorMessage.value = response.data?.error || '获取上游模型失败'
|
||||
}
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.response?.data?.detail || '获取上游模型失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换模型选择
|
||||
function toggleModel(modelId: string, checked?: boolean) {
|
||||
const shouldSelect = checked !== undefined ? checked : !selectedModels.value.includes(modelId)
|
||||
if (shouldSelect) {
|
||||
if (!selectedModels.value.includes(modelId)) {
|
||||
selectedModels.value = [...selectedModels.value, modelId]
|
||||
}
|
||||
} else {
|
||||
selectedModels.value = selectedModels.value.filter(id => id !== modelId)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
function toggleSelectAll(checked: boolean) {
|
||||
if (checked) {
|
||||
selectedModels.value = upstreamModels.value.map(m => m.id)
|
||||
} else {
|
||||
selectedModels.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogUpdate(value: boolean) {
|
||||
@@ -332,30 +332,44 @@ function handleCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.apiKey) return
|
||||
// 导入选中的模型
|
||||
async function handleImport() {
|
||||
if (!props.providerId || selectedModels.value.length === 0) return
|
||||
|
||||
// 检查是否有变化
|
||||
const hasChanged = !areArraysEqual(selectedModels.value, initialModels.value)
|
||||
if (!hasChanged) {
|
||||
emit('close')
|
||||
// 过滤出新模型(不在已存在列表中的)
|
||||
const modelsToImport = selectedModels.value.filter(id => !existingModelIds.value.has(id))
|
||||
if (modelsToImport.length === 0) {
|
||||
showError('所选模型都已存在', '提示')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
importing.value = true
|
||||
try {
|
||||
await updateEndpointKey(props.apiKey.id, {
|
||||
// 空数组时发送 null,表示允许所有模型
|
||||
allowed_models: selectedModels.value.length > 0 ? [...selectedModels.value] : null
|
||||
})
|
||||
success('允许的模型已更新', '成功')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
const response = await importModelsFromUpstream(props.providerId, modelsToImport)
|
||||
|
||||
const successCount = response.success?.length || 0
|
||||
const errorCount = response.errors?.length || 0
|
||||
|
||||
if (successCount > 0 && errorCount === 0) {
|
||||
success(`成功导入 ${successCount} 个模型`, '导入成功')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} else if (successCount > 0 && errorCount > 0) {
|
||||
success(`成功导入 ${successCount} 个模型,${errorCount} 个失败`, '部分成功')
|
||||
emit('saved')
|
||||
// 刷新列表以更新已存在状态
|
||||
await loadExistingModels()
|
||||
// 更新选中列表,移除已成功导入的
|
||||
const successIds = new Set(response.success?.map((s: { model_id: string }) => s.model_id) || [])
|
||||
selectedModels.value = selectedModels.value.filter(id => !successIds.has(id))
|
||||
} else {
|
||||
const errorMsg = response.errors?.[0]?.error || '导入失败'
|
||||
showError(errorMsg, '导入失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '保存失败')
|
||||
showError(errorMessage, '错误')
|
||||
showError(err.response?.data?.detail || '导入失败', '错误')
|
||||
} finally {
|
||||
saving.value = false
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
title="模型权限"
|
||||
:description="`管理密钥 ${props.apiKey?.name || ''} 可访问的模型,清空右侧列表表示允许全部`"
|
||||
:icon="Shield"
|
||||
size="4xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<template #default>
|
||||
<div class="space-y-4">
|
||||
<!-- 字典模式警告 -->
|
||||
<div
|
||||
v-if="isDictMode"
|
||||
class="rounded-lg border border-amber-500/50 bg-amber-50 dark:bg-amber-950/30 p-3"
|
||||
>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
<strong>注意:</strong>此密钥使用按 API 格式区分的模型权限配置。
|
||||
编辑后将转换为统一列表模式,原有的格式区分信息将丢失。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 密钥信息头部 -->
|
||||
<div class="rounded-lg border bg-muted/30 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-lg">{{ apiKey?.name }}</p>
|
||||
<p class="text-sm text-muted-foreground font-mono">
|
||||
{{ 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
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索模型..."
|
||||
class="pl-7 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<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-3.5 h-3.5"
|
||||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="从提供商获取模型"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<Zap class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div
|
||||
v-if="loadingGlobalModels"
|
||||
class="flex items-center justify-center h-full"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="totalAvailableCount === 0 && !upstreamModelsLoaded"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
>
|
||||
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
||||
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可添加模型' }}</p>
|
||||
</div>
|
||||
<div v-else class="p-2 space-y-2">
|
||||
<!-- 全局模型折叠组 -->
|
||||
<div
|
||||
v-if="availableGlobalModels.length > 0 || !upstreamModelsLoaded"
|
||||
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('global')"
|
||||
>
|
||||
<ChevronDown
|
||||
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 text-muted-foreground">
|
||||
({{ availableGlobalModels.length }})
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="availableGlobalModels.length > 0"
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline shrink-0"
|
||||
@click.stop="selectAllGlobalModels"
|
||||
>
|
||||
{{ isAllGlobalModelsSelected ? '取消' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has('global')"
|
||||
class="p-2 space-y-1 border-t"
|
||||
>
|
||||
<div
|
||||
v-if="availableGlobalModels.length === 0"
|
||||
class="py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
所有全局模型均已添加
|
||||
</div>
|
||||
<div
|
||||
v-for="model in availableGlobalModels"
|
||||
v-else
|
||||
:key="model.name"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||
:class="selectedLeftIds.includes(model.name)
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'hover:bg-muted/50'"
|
||||
@click="toggleLeftSelection(model.name)"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedLeftIds.includes(model.name)"
|
||||
@update:checked="toggleLeftSelection(model.name)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">{{ model.display_name }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 从提供商获取的模型折叠组 -->
|
||||
<div
|
||||
v-for="group in upstreamModelGroups"
|
||||
: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)"
|
||||
>
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has(group.api_format) ? '-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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline shrink-0"
|
||||
@click.stop="selectAllUpstreamModels(group.api_format)"
|
||||
>
|
||||
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has(group.api_format)"
|
||||
class="p-2 space-y-1 border-t"
|
||||
>
|
||||
<div
|
||||
v-for="model in group.models"
|
||||
:key="model.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||
:class="selectedLeftIds.includes(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
|
||||
v-if="allowedModels.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
>
|
||||
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
||||
<p class="text-sm">允许访问全部模型</p>
|
||||
<p 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ hasChanges ? '有未保存的更改' : '' }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" @click="handleCancel">取消</Button>
|
||||
<Button :disabled="saving || !hasChanges" @click="handleSave">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import {
|
||||
Shield,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Zap,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
ChevronDown
|
||||
} from 'lucide-vue-next'
|
||||
import { Dialog, Button, Input, Checkbox, Badge } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
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 type { UpstreamModel } from '@/api/endpoints/types'
|
||||
|
||||
interface AvailableModel {
|
||||
name: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
apiKey: EndpointAPIKey | null
|
||||
providerId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
const loadingGlobalModels = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
|
||||
// 用于取消异步操作的标志
|
||||
let loadingCancelled = false
|
||||
|
||||
// 搜索
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 折叠状态
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 可用模型列表(全局模型)
|
||||
const allGlobalModels = ref<AvailableModel[]>([])
|
||||
// 上游模型列表
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
|
||||
// 已添加的允许模型(右侧)
|
||||
const allowedModels = ref<string[]>([])
|
||||
const initialAllowedModels = ref<string[]>([])
|
||||
|
||||
// 选中状态
|
||||
const selectedLeftIds = ref<string[]>([])
|
||||
const selectedRightIds = ref<string[]>([])
|
||||
|
||||
// 是否有更改
|
||||
const hasChanges = computed(() => {
|
||||
if (allowedModels.value.length !== initialAllowedModels.value.length) return true
|
||||
const sorted1 = [...allowedModels.value].sort()
|
||||
const sorted2 = [...initialAllowedModels.value].sort()
|
||||
return sorted1.some((v, i) => v !== sorted2[i])
|
||||
})
|
||||
|
||||
// 计算可添加的全局模型(排除已添加的)
|
||||
const availableGlobalModelsBase = computed(() => {
|
||||
const allowedSet = new Set(allowedModels.value)
|
||||
return allGlobalModels.value.filter(m => !allowedSet.has(m.name))
|
||||
})
|
||||
|
||||
// 搜索过滤后的全局模型
|
||||
const availableGlobalModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return availableGlobalModelsBase.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return availableGlobalModelsBase.value.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.display_name.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// 计算可添加的上游模型(排除已添加的)
|
||||
const availableUpstreamModelsBase = computed(() => {
|
||||
const allowedSet = new Set(allowedModels.value)
|
||||
return upstreamModels.value.filter(m => !allowedSet.has(m.id))
|
||||
})
|
||||
|
||||
// 搜索过滤后的上游模型
|
||||
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[]> = {}
|
||||
for (const model of availableUpstreamModels.value) {
|
||||
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 }))
|
||||
.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 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(() => {
|
||||
if (availableGlobalModels.value.length === 0) return false
|
||||
return availableGlobalModels.value.every(m => selectedLeftIds.value.includes(m.name))
|
||||
})
|
||||
|
||||
// 检查某个上游组是否全选
|
||||
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
|
||||
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
|
||||
if (!group || group.models.length === 0) return false
|
||||
return group.models.every(m => selectedLeftIds.value.includes(m.id))
|
||||
}
|
||||
|
||||
// 获取模型显示名称
|
||||
function getModelDisplayName(name: string): string {
|
||||
const globalModel = allGlobalModels.value.find(m => m.name === name)
|
||||
if (globalModel) return globalModel.display_name
|
||||
const upstreamModel = upstreamModels.value.find(m => m.id === name)
|
||||
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 {
|
||||
showError(response.data?.error || '获取上游模型失败', '错误')
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (loadingCancelled) return
|
||||
showError(err.response?.data?.detail || '获取上游模型失败', '错误')
|
||||
} finally {
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换折叠状态
|
||||
function toggleGroupCollapse(group: string) {
|
||||
if (collapsedGroups.value.has(group)) {
|
||||
collapsedGroups.value.delete(group)
|
||||
} else {
|
||||
collapsedGroups.value.add(group)
|
||||
}
|
||||
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||
}
|
||||
|
||||
// 是否为字典模式(按 API 格式区分)
|
||||
const isDictMode = ref(false)
|
||||
|
||||
// 解析 allowed_models
|
||||
function parseAllowedModels(allowed: AllowedModels): string[] {
|
||||
if (allowed === null || allowed === undefined) {
|
||||
isDictMode.value = false
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(allowed)) {
|
||||
isDictMode.value = false
|
||||
return [...allowed]
|
||||
}
|
||||
// 字典模式:合并所有格式的模型,并设置警告标志
|
||||
isDictMode.value = true
|
||||
const all = new Set<string>()
|
||||
for (const models of Object.values(allowed)) {
|
||||
models.forEach(m => all.add(m))
|
||||
}
|
||||
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) => {
|
||||
if (open && props.apiKey) {
|
||||
// 重置取消标志
|
||||
loadingCancelled = false
|
||||
|
||||
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
|
||||
allowedModels.value = [...parsed]
|
||||
initialAllowedModels.value = [...parsed]
|
||||
selectedLeftIds.value = []
|
||||
selectedRightIds.value = []
|
||||
searchQuery.value = ''
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
collapsedGroups.value = new Set()
|
||||
|
||||
await loadGlobalModels()
|
||||
} else {
|
||||
// dialog 关闭时设置取消标志
|
||||
loadingCancelled = true
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时取消所有异步操作
|
||||
onUnmounted(() => {
|
||||
loadingCancelled = true
|
||||
})
|
||||
|
||||
function handleDialogUpdate(value: boolean) {
|
||||
if (!value) emit('close')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.apiKey) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 空列表 = null(允许全部)
|
||||
const newAllowed: AllowedModels = allowedModels.value.length > 0
|
||||
? [...allowedModels.value]
|
||||
: null
|
||||
|
||||
await updateProviderKey(props.apiKey.id, { allowed_models: newAllowed })
|
||||
success('模型权限已更新', '成功')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
showError(parseApiError(err, '保存失败'), '错误')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,57 +2,36 @@
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
:title="isEditMode ? '编辑密钥' : '添加密钥'"
|
||||
:description="isEditMode ? '修改 API 密钥配置' : '为端点添加新的 API 密钥'"
|
||||
:description="isEditMode ? '修改 API 密钥配置' : '为提供商添加新的 API 密钥'"
|
||||
:icon="isEditMode ? SquarePen : Key"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<form
|
||||
class="space-y-5"
|
||||
class="space-y-4"
|
||||
autocomplete="off"
|
||||
@submit.prevent="handleSave"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
基本信息
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label :for="keyNameInputId">密钥名称 *</Label>
|
||||
<Input
|
||||
:id="keyNameInputId"
|
||||
v-model="form.name"
|
||||
:name="keyNameFieldName"
|
||||
required
|
||||
placeholder="例如:主 Key、备用 Key 1"
|
||||
maxlength="100"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="rate_multiplier">成本倍率 *</Label>
|
||||
<Input
|
||||
id="rate_multiplier"
|
||||
v-model.number="form.rate_multiplier"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
placeholder="1.0"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
真实成本 = 表面成本 × 倍率
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label :for="keyNameInputId">密钥名称 *</Label>
|
||||
<Input
|
||||
:id="keyNameInputId"
|
||||
v-model="form.name"
|
||||
:name="keyNameFieldName"
|
||||
required
|
||||
placeholder="例如:主 Key、备用 Key 1"
|
||||
maxlength="100"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label :for="apiKeyInputId">API 密钥 {{ editingKey ? '' : '*' }}</Label>
|
||||
<Input
|
||||
@@ -83,148 +62,161 @@
|
||||
v-else-if="editingKey"
|
||||
class="text-xs text-muted-foreground mt-1"
|
||||
>
|
||||
留空表示不修改,输入新值则覆盖
|
||||
留空表示不修改
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 备注 -->
|
||||
<div>
|
||||
<Label for="note">备注</Label>
|
||||
<Input
|
||||
id="note"
|
||||
v-model="form.note"
|
||||
placeholder="可选的备注信息"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- API 格式选择 -->
|
||||
<div v-if="sortedApiFormats.length > 0">
|
||||
<Label class="mb-1.5 block">支持的 API 格式 *</Label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="format in sortedApiFormats"
|
||||
:key="format"
|
||||
class="flex items-center justify-between rounded-md border px-2 py-1.5 transition-colors cursor-pointer"
|
||||
:class="form.api_formats.includes(format)
|
||||
? 'bg-primary/5 border-primary/30'
|
||||
: 'bg-muted/30 border-border hover:border-muted-foreground/30'"
|
||||
@click="toggleApiFormat(format)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
class="w-4 h-4 rounded border flex items-center justify-center text-xs shrink-0"
|
||||
:class="form.api_formats.includes(format)
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/30'"
|
||||
>
|
||||
<span v-if="form.api_formats.includes(format)">✓</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-sm whitespace-nowrap"
|
||||
:class="form.api_formats.includes(format) ? 'text-primary' : 'text-muted-foreground'"
|
||||
>{{ API_FORMAT_LABELS[format] || format }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center shrink-0 ml-2 text-xs text-muted-foreground gap-1"
|
||||
@click.stop
|
||||
>
|
||||
<span>×</span>
|
||||
<input
|
||||
:value="form.rate_multipliers[format] ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="1"
|
||||
class="w-9 bg-transparent text-right outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
:class="form.api_formats.includes(format) ? 'text-primary' : 'text-muted-foreground'"
|
||||
title="成本倍率"
|
||||
@input="(e) => updateRateMultiplier(format, (e.target as HTMLInputElement).value)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置项 -->
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label for="note">备注</Label>
|
||||
<Label
|
||||
for="internal_priority"
|
||||
class="text-xs"
|
||||
>优先级</Label>
|
||||
<Input
|
||||
id="note"
|
||||
v-model="form.note"
|
||||
placeholder="可选的备注信息"
|
||||
id="internal_priority"
|
||||
v-model.number="form.internal_priority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
越小越优先
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="rpm_limit"
|
||||
class="text-xs"
|
||||
>RPM 限制</Label>
|
||||
<Input
|
||||
id="rpm_limit"
|
||||
:model-value="form.rpm_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="自适应"
|
||||
class="h-8"
|
||||
@update:model-value="(v) => form.rpm_limit = parseNullableNumberInput(v, { min: 1, max: 10000 })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
留空自适应
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="cache_ttl_minutes"
|
||||
class="text-xs"
|
||||
>缓存 TTL</Label>
|
||||
<Input
|
||||
id="cache_ttl_minutes"
|
||||
:model-value="form.cache_ttl_minutes ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="60"
|
||||
class="h-8"
|
||||
@update:model-value="(v) => form.cache_ttl_minutes = parseNumberInput(v, { min: 0, max: 60 }) ?? 5"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
分钟,0禁用
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="max_probe_interval_minutes"
|
||||
class="text-xs"
|
||||
>熔断探测</Label>
|
||||
<Input
|
||||
id="max_probe_interval_minutes"
|
||||
:model-value="form.max_probe_interval_minutes ?? ''"
|
||||
type="number"
|
||||
min="2"
|
||||
max="32"
|
||||
placeholder="32"
|
||||
class="h-8"
|
||||
@update:model-value="(v) => form.max_probe_interval_minutes = parseNumberInput(v, { min: 2, max: 32 }) ?? 32"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
分钟,2-32
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 调度与限流 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
调度与限流
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="internal_priority">内部优先级</Label>
|
||||
<Input
|
||||
id="internal_priority"
|
||||
v-model.number="form.internal_priority"
|
||||
type="number"
|
||||
min="0"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
数字越小越优先
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="max_concurrent">最大并发</Label>
|
||||
<Input
|
||||
id="max_concurrent"
|
||||
:model-value="form.max_concurrent ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空启用自适应"
|
||||
@update:model-value="(v) => form.max_concurrent = parseNumberInput(v)"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
留空 = 自适应模式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label for="rate_limit">速率限制(/分钟)</Label>
|
||||
<Input
|
||||
id="rate_limit"
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="daily_limit">每日限制</Label>
|
||||
<Input
|
||||
id="daily_limit"
|
||||
:model-value="form.daily_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
@update:model-value="(v) => form.daily_limit = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="monthly_limit">每月限制</Label>
|
||||
<Input
|
||||
id="monthly_limit"
|
||||
:model-value="form.monthly_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
@update:model-value="(v) => form.monthly_limit = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缓存与熔断 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
缓存与熔断
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="cache_ttl_minutes">缓存 TTL (分钟)</Label>
|
||||
<Input
|
||||
id="cache_ttl_minutes"
|
||||
:model-value="form.cache_ttl_minutes ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="60"
|
||||
@update:model-value="(v) => form.cache_ttl_minutes = parseNumberInput(v, { min: 0, max: 60 }) ?? 5"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
0 = 禁用缓存亲和性
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="max_probe_interval_minutes">熔断探测间隔 (分钟)</Label>
|
||||
<Input
|
||||
id="max_probe_interval_minutes"
|
||||
:model-value="form.max_probe_interval_minutes ?? ''"
|
||||
type="number"
|
||||
min="2"
|
||||
max="32"
|
||||
placeholder="32"
|
||||
@update:model-value="(v) => form.max_probe_interval_minutes = parseNumberInput(v, { min: 2, max: 32 }) ?? 32"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
范围 2-32 分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 能力标签配置 -->
|
||||
<div
|
||||
v-if="availableCapabilities.length > 0"
|
||||
class="space-y-3"
|
||||
>
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
能力标签
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
<!-- 能力标签 -->
|
||||
<div v-if="availableCapabilities.length > 0">
|
||||
<Label class="text-xs mb-1.5 block">能力标签</Label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="cap in availableCapabilities"
|
||||
:key="cap.name"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-sm transition-colors"
|
||||
:class="form.capabilities[cap.name]
|
||||
? 'bg-primary/10 border-primary/50 text-primary'
|
||||
: 'bg-card border-border hover:bg-muted/50 text-muted-foreground'"
|
||||
@click="form.capabilities[cap.name] = !form.capabilities[cap.name]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.capabilities[cap.name] || false"
|
||||
class="rounded"
|
||||
@change="form.capabilities[cap.name] = !form.capabilities[cap.name]"
|
||||
>
|
||||
<span>{{ cap.display_name }}</span>
|
||||
</label>
|
||||
{{ cap.display_name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -247,18 +239,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Dialog, Button, Input, Label } from '@/components/ui'
|
||||
import { Key, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
addEndpointKey,
|
||||
updateEndpointKey,
|
||||
addProviderKey,
|
||||
updateProviderKey,
|
||||
getAllCapabilities,
|
||||
API_FORMAT_LABELS,
|
||||
sortApiFormats,
|
||||
type EndpointAPIKey,
|
||||
type EndpointAPIKeyUpdate,
|
||||
type ProviderEndpoint,
|
||||
@@ -270,6 +264,7 @@ const props = defineProps<{
|
||||
endpoint: ProviderEndpoint | null
|
||||
editingKey: EndpointAPIKey | null
|
||||
providerId: string | null
|
||||
availableApiFormats: string[] // Provider 支持的所有 API 格式
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -279,6 +274,9 @@ const emit = defineEmits<{
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 排序后的可用 API 格式列表
|
||||
const sortedApiFormats = computed(() => sortApiFormats(props.availableApiFormats))
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
const formNonce = ref(createFieldNonce())
|
||||
@@ -297,12 +295,10 @@ const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const form = ref({
|
||||
name: '',
|
||||
api_key: '',
|
||||
rate_multiplier: 1.0,
|
||||
internal_priority: 50,
|
||||
max_concurrent: undefined as number | undefined,
|
||||
rate_limit: undefined as number | undefined,
|
||||
daily_limit: undefined as number | undefined,
|
||||
monthly_limit: undefined as number | undefined,
|
||||
api_formats: [] as string[], // 支持的 API 格式列表
|
||||
rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率
|
||||
internal_priority: 10,
|
||||
rpm_limit: undefined as number | null | undefined, // RPM 限制(null=自适应,undefined=保持原值)
|
||||
cache_ttl_minutes: 5,
|
||||
max_probe_interval_minutes: 32,
|
||||
note: '',
|
||||
@@ -323,6 +319,43 @@ onMounted(() => {
|
||||
loadCapabilities()
|
||||
})
|
||||
|
||||
// API 格式切换
|
||||
function toggleApiFormat(format: string) {
|
||||
const index = form.value.api_formats.indexOf(format)
|
||||
if (index === -1) {
|
||||
// 添加格式
|
||||
form.value.api_formats.push(format)
|
||||
} else {
|
||||
// 移除格式前检查:至少保留一个格式
|
||||
if (form.value.api_formats.length <= 1) {
|
||||
showError('至少需要选择一个 API 格式', '验证失败')
|
||||
return
|
||||
}
|
||||
// 移除格式,但保留倍率配置(用户可能只是临时取消)
|
||||
form.value.api_formats.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新指定格式的成本倍率
|
||||
function updateRateMultiplier(format: string, value: string | number) {
|
||||
// 使用对象替换以确保 Vue 3 响应性
|
||||
const newMultipliers = { ...form.value.rate_multipliers }
|
||||
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
// 清空时删除该格式的配置(使用默认倍率)
|
||||
delete newMultipliers[format]
|
||||
} else {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value
|
||||
// 限制倍率范围:0.01 - 100
|
||||
if (!isNaN(numValue) && numValue >= 0.01 && numValue <= 100) {
|
||||
newMultipliers[format] = numValue
|
||||
}
|
||||
}
|
||||
|
||||
// 替换整个对象以触发响应式更新
|
||||
form.value.rate_multipliers = newMultipliers
|
||||
}
|
||||
|
||||
// API 密钥输入框样式计算
|
||||
function getApiKeyInputClass(): string {
|
||||
const classes = []
|
||||
@@ -363,12 +396,10 @@ function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
api_key: '',
|
||||
rate_multiplier: 1.0,
|
||||
internal_priority: 50,
|
||||
max_concurrent: undefined,
|
||||
rate_limit: undefined,
|
||||
daily_limit: undefined,
|
||||
monthly_limit: undefined,
|
||||
api_formats: [], // 默认不选中任何格式
|
||||
rate_multipliers: {},
|
||||
internal_priority: 10,
|
||||
rpm_limit: undefined,
|
||||
cache_ttl_minutes: 5,
|
||||
max_probe_interval_minutes: 32,
|
||||
note: '',
|
||||
@@ -385,13 +416,13 @@ function loadKeyData() {
|
||||
form.value = {
|
||||
name: props.editingKey.name,
|
||||
api_key: '',
|
||||
rate_multiplier: props.editingKey.rate_multiplier || 1.0,
|
||||
internal_priority: props.editingKey.internal_priority ?? 50,
|
||||
api_formats: props.editingKey.api_formats?.length > 0
|
||||
? [...props.editingKey.api_formats]
|
||||
: [], // 编辑模式下保持原有选择,不默认全选
|
||||
rate_multipliers: { ...(props.editingKey.rate_multipliers || {}) },
|
||||
internal_priority: props.editingKey.internal_priority ?? 10,
|
||||
// 保留原始的 null/undefined 状态,null 表示自适应模式
|
||||
max_concurrent: props.editingKey.max_concurrent ?? undefined,
|
||||
rate_limit: props.editingKey.rate_limit ?? undefined,
|
||||
daily_limit: props.editingKey.daily_limit ?? undefined,
|
||||
monthly_limit: props.editingKey.monthly_limit ?? undefined,
|
||||
rpm_limit: props.editingKey.rpm_limit ?? undefined,
|
||||
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
|
||||
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
|
||||
note: props.editingKey.note || '',
|
||||
@@ -415,7 +446,11 @@ function createFieldNonce(): string {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.endpoint) return
|
||||
// 必须有 providerId
|
||||
if (!props.providerId) {
|
||||
showError('无法保存:缺少提供商信息', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
// 提交前验证
|
||||
if (apiKeyError.value) {
|
||||
@@ -429,6 +464,12 @@ async function handleSave() {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证至少选择一个 API 格式
|
||||
if (form.value.api_formats.length === 0) {
|
||||
showError('请至少选择一个 API 格式', '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤出有效的能力配置(只包含值为 true 的)
|
||||
const activeCapabilities: Record<string, boolean> = {}
|
||||
for (const [key, value] of Object.entries(form.value.capabilities)) {
|
||||
@@ -440,21 +481,27 @@ async function handleSave() {
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 准备 rate_multipliers 数据:只保留已选中格式的倍率配置
|
||||
const filteredMultipliers: Record<string, number> = {}
|
||||
for (const format of form.value.api_formats) {
|
||||
if (form.value.rate_multipliers[format] !== undefined) {
|
||||
filteredMultipliers[format] = form.value.rate_multipliers[format]
|
||||
}
|
||||
}
|
||||
const rateMultipliersData = Object.keys(filteredMultipliers).length > 0
|
||||
? filteredMultipliers
|
||||
: null
|
||||
|
||||
if (props.editingKey) {
|
||||
// 更新模式
|
||||
// 注意:max_concurrent 需要显式发送 null 来切换到自适应模式
|
||||
// undefined 会在 JSON 中被忽略,所以用 null 表示"清空/自适应"
|
||||
// 注意:rpm_limit 使用 null 表示自适应模式
|
||||
// undefined 表示"保持原值不变"(会在 JSON 序列化时被忽略)
|
||||
const updateData: EndpointAPIKeyUpdate = {
|
||||
api_formats: form.value.api_formats,
|
||||
name: form.value.name,
|
||||
rate_multiplier: form.value.rate_multiplier,
|
||||
rate_multipliers: rateMultipliersData,
|
||||
internal_priority: form.value.internal_priority,
|
||||
// 显式使用 null 表示自适应模式,这样后端能区分"未提供"和"设置为 null"
|
||||
// 注意:只有 max_concurrent 需要这种处理,因为它有"自适应模式"的概念
|
||||
// 其他限制字段(rate_limit 等)不支持"清空"操作,undefined 会被 JSON 忽略即不更新
|
||||
max_concurrent: form.value.max_concurrent === undefined ? null : form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
daily_limit: form.value.daily_limit,
|
||||
monthly_limit: form.value.monthly_limit,
|
||||
rpm_limit: form.value.rpm_limit,
|
||||
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||
note: form.value.note,
|
||||
@@ -466,20 +513,17 @@ async function handleSave() {
|
||||
updateData.api_key = form.value.api_key
|
||||
}
|
||||
|
||||
await updateEndpointKey(props.editingKey.id, updateData)
|
||||
await updateProviderKey(props.editingKey.id, updateData)
|
||||
success('密钥已更新', '成功')
|
||||
} else {
|
||||
// 新增
|
||||
await addEndpointKey(props.endpoint.id, {
|
||||
endpoint_id: props.endpoint.id,
|
||||
// 新增模式
|
||||
await addProviderKey(props.providerId, {
|
||||
api_formats: form.value.api_formats,
|
||||
api_key: form.value.api_key,
|
||||
name: form.value.name,
|
||||
rate_multiplier: form.value.rate_multiplier,
|
||||
rate_multipliers: rateMultipliersData,
|
||||
internal_priority: form.value.internal_priority,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
daily_limit: form.value.daily_limit,
|
||||
monthly_limit: form.value.monthly_limit,
|
||||
rpm_limit: form.value.rpm_limit,
|
||||
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||
note: form.value.note,
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
<!-- 提供商信息 -->
|
||||
<div class="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span class="font-medium text-sm truncate">{{ provider.display_name }}</span>
|
||||
<span class="font-medium text-sm truncate">{{ provider.name }}</span>
|
||||
<Badge
|
||||
v-if="!provider.is_active"
|
||||
variant="secondary"
|
||||
@@ -395,7 +395,7 @@ import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { updateProvider, updateEndpointKey } from '@/api/endpoints'
|
||||
import { updateProvider, updateProviderKey } from '@/api/endpoints'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
@@ -696,7 +696,7 @@ async function save() {
|
||||
const keys = keysByFormat.value[format]
|
||||
keys.forEach((key) => {
|
||||
// 使用用户设置的 priority 值,相同 priority 会做负载均衡
|
||||
keyUpdates.push(updateEndpointKey(key.id, { global_priority: key.priority }))
|
||||
keyUpdates.push(updateProviderKey(key.id, { global_priority: key.priority }))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,47 +4,29 @@
|
||||
:title="isEditMode ? '编辑提供商' : '添加提供商'"
|
||||
:description="isEditMode ? '更新提供商配置。API 端点和密钥需在详情页面单独管理。' : '创建新的提供商配置。创建后可以为其添加 API 端点和密钥。'"
|
||||
:icon="isEditMode ? SquarePen : Server"
|
||||
size="2xl"
|
||||
size="xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<form
|
||||
class="space-y-6"
|
||||
class="space-y-5"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
基本信息
|
||||
</h3>
|
||||
|
||||
<!-- 添加模式显示提供商标识 -->
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label for="name">提供商标识 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
placeholder="例如: openai-primary"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
唯一ID,创建后不可修改
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="display_name">显示名称 *</Label>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="name">名称 *</Label>
|
||||
<Input
|
||||
id="display_name"
|
||||
v-model="form.display_name"
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
placeholder="例如: OpenAI 主账号"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="website">主站链接</Label>
|
||||
<Input
|
||||
id="website"
|
||||
@@ -55,24 +37,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="description">描述</Label>
|
||||
<Textarea
|
||||
<Input
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="提供商描述(可选)"
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 计费与限流 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
计费与限流
|
||||
</h3>
|
||||
<!-- 计费与限流 / 请求配置 -->
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
计费与限流
|
||||
</h3>
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
请求配置
|
||||
</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>计费类型</Label>
|
||||
<Select
|
||||
v-model="form.billing_type"
|
||||
@@ -82,27 +68,35 @@
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly_quota">
|
||||
月卡额度
|
||||
</SelectItem>
|
||||
<SelectItem value="pay_as_you_go">
|
||||
按量付费
|
||||
</SelectItem>
|
||||
<SelectItem value="free_tier">
|
||||
免费套餐
|
||||
</SelectItem>
|
||||
<SelectItem value="monthly_quota">月卡额度</SelectItem>
|
||||
<SelectItem value="pay_as_you_go">按量付费</SelectItem>
|
||||
<SelectItem value="free_tier">免费套餐</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>RPM 限制</Label>
|
||||
<Input
|
||||
:model-value="form.rpm_limit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="不限制请留空"
|
||||
@update:model-value="(v) => form.rpm_limit = parseNumberInput(v)"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>超时时间 (秒)</Label>
|
||||
<Input
|
||||
:model-value="form.timeout ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="600"
|
||||
placeholder="默认 300"
|
||||
@update:model-value="(v) => form.timeout = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>最大重试次数</Label>
|
||||
<Input
|
||||
:model-value="form.max_retries ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
placeholder="默认 2"
|
||||
@update:model-value="(v) => form.max_retries = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,52 +105,94 @@
|
||||
v-if="form.billing_type === 'monthly_quota'"
|
||||
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">周期额度 (USD)</Label>
|
||||
<Input
|
||||
:model-value="form.monthly_quota_usd ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-9"
|
||||
@update:model-value="(v) => form.monthly_quota_usd = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">重置周期 (天)</Label>
|
||||
<Input
|
||||
:model-value="form.quota_reset_day ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
class="h-9"
|
||||
@update:model-value="(v) => form.quota_reset_day = parseNumberInput(v) ?? 30"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">
|
||||
周期开始时间
|
||||
<span class="text-red-500">*</span>
|
||||
周期开始时间 <span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
v-model="form.quota_last_reset_at"
|
||||
type="datetime-local"
|
||||
class="h-9"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
系统会自动统计从该时间点开始的使用量
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">过期时间</Label>
|
||||
<Input
|
||||
v-model="form.quota_expires_at"
|
||||
type="datetime-local"
|
||||
class="h-9"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
留空表示永久有效
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理配置 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">
|
||||
代理配置
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
:model-value="form.proxy_enabled"
|
||||
@update:model-value="(v: boolean) => form.proxy_enabled = v"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">启用代理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.proxy_enabled"
|
||||
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">代理地址 *</Label>
|
||||
<Input
|
||||
v-model="form.proxy_url"
|
||||
placeholder="http://proxy:port 或 socks5://proxy:port"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">用户名</Label>
|
||||
<Input
|
||||
v-model="form.proxy_username"
|
||||
placeholder="可选"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">密码</Label>
|
||||
<Input
|
||||
v-model="form.proxy_password"
|
||||
type="password"
|
||||
placeholder="可选"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +208,7 @@
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="loading || !form.display_name || (!isEditMode && !form.name)"
|
||||
:disabled="loading || !form.name"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存' : '创建') }}
|
||||
@@ -187,13 +223,13 @@ import {
|
||||
Dialog,
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
Label,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Switch,
|
||||
} from '@/components/ui'
|
||||
import { Server, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -223,7 +259,6 @@ const internalOpen = computed(() => props.modelValue)
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
name: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
website: '',
|
||||
// 计费配置
|
||||
@@ -232,19 +267,25 @@ const form = ref({
|
||||
quota_reset_day: 30,
|
||||
quota_last_reset_at: '', // 周期开始时间
|
||||
quota_expires_at: '',
|
||||
rpm_limit: undefined as string | number | undefined,
|
||||
provider_priority: 999,
|
||||
// 状态配置
|
||||
is_active: true,
|
||||
rate_limit: undefined as number | undefined,
|
||||
concurrent_limit: undefined as number | undefined,
|
||||
// 请求配置
|
||||
timeout: undefined as number | undefined,
|
||||
max_retries: undefined as number | undefined,
|
||||
// 代理配置(扁平化便于表单绑定)
|
||||
proxy_enabled: false,
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
website: '',
|
||||
billing_type: 'pay_as_you_go',
|
||||
@@ -252,11 +293,18 @@ function resetForm() {
|
||||
quota_reset_day: 30,
|
||||
quota_last_reset_at: '',
|
||||
quota_expires_at: '',
|
||||
rpm_limit: undefined,
|
||||
provider_priority: 999,
|
||||
is_active: true,
|
||||
rate_limit: undefined,
|
||||
concurrent_limit: undefined,
|
||||
// 请求配置
|
||||
timeout: undefined,
|
||||
max_retries: undefined,
|
||||
// 代理配置
|
||||
proxy_enabled: false,
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,9 +312,9 @@ function resetForm() {
|
||||
function loadProviderData() {
|
||||
if (!props.provider) return
|
||||
|
||||
const proxy = props.provider.proxy
|
||||
form.value = {
|
||||
name: props.provider.name,
|
||||
display_name: props.provider.display_name,
|
||||
description: props.provider.description || '',
|
||||
website: props.provider.website || '',
|
||||
billing_type: (props.provider.billing_type as 'monthly_quota' | 'pay_as_you_go' | 'free_tier') || 'pay_as_you_go',
|
||||
@@ -276,11 +324,18 @@ function loadProviderData() {
|
||||
new Date(props.provider.quota_last_reset_at).toISOString().slice(0, 16) : '',
|
||||
quota_expires_at: props.provider.quota_expires_at ?
|
||||
new Date(props.provider.quota_expires_at).toISOString().slice(0, 16) : '',
|
||||
rpm_limit: props.provider.rpm_limit ?? undefined,
|
||||
provider_priority: props.provider.provider_priority || 999,
|
||||
is_active: props.provider.is_active,
|
||||
rate_limit: undefined,
|
||||
concurrent_limit: undefined,
|
||||
// 请求配置
|
||||
timeout: props.provider.timeout ?? undefined,
|
||||
max_retries: props.provider.max_retries ?? undefined,
|
||||
// 代理配置
|
||||
proxy_enabled: proxy?.enabled ?? false,
|
||||
proxy_url: proxy?.url || '',
|
||||
proxy_username: proxy?.username || '',
|
||||
proxy_password: proxy?.password || '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,17 +357,37 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 启用代理时必须填写代理地址
|
||||
if (form.value.proxy_enabled && !form.value.proxy_url) {
|
||||
showError('启用代理时必须填写代理地址', '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 构建代理配置
|
||||
const proxy = form.value.proxy_enabled ? {
|
||||
url: form.value.proxy_url,
|
||||
username: form.value.proxy_username || undefined,
|
||||
password: form.value.proxy_password || undefined,
|
||||
enabled: true,
|
||||
} : null
|
||||
|
||||
const payload = {
|
||||
...form.value,
|
||||
rpm_limit:
|
||||
form.value.rpm_limit === undefined || form.value.rpm_limit === ''
|
||||
? null
|
||||
: Number(form.value.rpm_limit),
|
||||
// 空字符串时不发送
|
||||
name: form.value.name,
|
||||
description: form.value.description || undefined,
|
||||
website: form.value.website || undefined,
|
||||
billing_type: form.value.billing_type,
|
||||
monthly_quota_usd: form.value.monthly_quota_usd,
|
||||
quota_reset_day: form.value.quota_reset_day,
|
||||
quota_last_reset_at: form.value.quota_last_reset_at || undefined,
|
||||
quota_expires_at: form.value.quota_expires_at || undefined,
|
||||
provider_priority: form.value.provider_priority,
|
||||
is_active: form.value.is_active,
|
||||
// 请求配置
|
||||
timeout: form.value.timeout ?? undefined,
|
||||
max_retries: form.value.max_retries ?? undefined,
|
||||
proxy,
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.provider) {
|
||||
|
||||
@@ -2,6 +2,7 @@ export { default as ProviderFormDialog } from './ProviderFormDialog.vue'
|
||||
export { default as EndpointFormDialog } from './EndpointFormDialog.vue'
|
||||
export { default as KeyFormDialog } from './KeyFormDialog.vue'
|
||||
export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue'
|
||||
export { default as KeyAllowedModelsEditDialog } from './KeyAllowedModelsEditDialog.vue'
|
||||
export { default as PriorityManagementDialog } from './PriorityManagementDialog.vue'
|
||||
export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vue'
|
||||
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
||||
|
||||
Reference in New Issue
Block a user