refactor: 重构限流系统和健康监控,支持按 API 格式区分

- 将 adaptive_concurrency 重命名为 adaptive_rpm,从并发控制改为 RPM 控制
- 健康监控器支持按 API 格式独立管理健康度和熔断器状态
- 新增 model_permissions 模块,支持按格式配置允许的模型
- 重构前端提供商相关表单组件,新增 Collapsible UI 组件
- 新增数据库迁移脚本支持新的数据结构
This commit is contained in:
fawney19
2026-01-10 18:43:53 +08:00
parent dd2fbf4424
commit 09e0f594ff
97 changed files with 6642 additions and 4169 deletions

View File

@@ -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"
>
支持 HTTPHTTPSSOCKS5 代理
</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>

View File

@@ -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)"
>
&times;
</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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 }))
})
}

View File

@@ -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) {

View File

@@ -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'