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

@@ -67,7 +67,6 @@ export interface GlobalModelExport {
export interface ProviderExport {
name: string
display_name: string
description?: string | null
website?: string | null
billing_type?: string | null
@@ -76,10 +75,13 @@ export interface ProviderExport {
rpm_limit?: number | null
provider_priority?: number
is_active: boolean
rate_limit?: number | null
concurrent_limit?: number | null
timeout?: number | null
max_retries?: number | null
proxy?: any
config?: any
endpoints: EndpointExport[]
api_keys: ProviderKeyExport[]
models: ModelExport[]
}
@@ -89,27 +91,26 @@ export interface EndpointExport {
headers?: any
timeout?: number
max_retries?: number
max_concurrent?: number | null
rate_limit?: number | null
is_active: boolean
custom_path?: string | null
config?: any
keys: KeyExport[]
proxy?: any
}
export interface KeyExport {
export interface ProviderKeyExport {
api_key: string
name?: string | null
note?: string | null
api_formats: string[]
rate_multiplier?: number
rate_multipliers?: Record<string, number> | null
internal_priority?: number
global_priority?: number | null
max_concurrent?: number | null
rate_limit?: number | null
daily_limit?: number | null
monthly_limit?: number | null
allowed_models?: string[] | null
rpm_limit?: number | null
allowed_models?: any
capabilities?: any
cache_ttl_minutes?: number
max_probe_interval_minutes?: number
is_active: boolean
}

View File

@@ -155,6 +155,7 @@ export interface RequestDetail {
request_body?: Record<string, any>
provider_request_headers?: Record<string, any>
response_headers?: Record<string, any>
client_response_headers?: Record<string, any>
response_body?: Record<string, any>
metadata?: Record<string, any>
// 阶梯计费信息

View File

@@ -14,7 +14,7 @@ export async function toggleAdaptiveMode(
message: string
key_id: string
is_adaptive: boolean
max_concurrent: number | null
rpm_limit: number | null
effective_limit: number | null
}> {
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/mode`, data)
@@ -22,16 +22,16 @@ export async function toggleAdaptiveMode(
}
/**
* 设置 Key 的固定并发限制
* 设置 Key 的固定 RPM 限制
*/
export async function setConcurrentLimit(
export async function setRpmLimit(
keyId: string,
limit: number
): Promise<{
message: string
key_id: string
is_adaptive: boolean
max_concurrent: number
rpm_limit: number
previous_mode: string
}> {
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/limit`, null, {

View File

@@ -27,15 +27,9 @@ export async function createEndpoint(
api_format: string
base_url: string
custom_path?: string
auth_type?: string
auth_header?: string
headers?: Record<string, string>
timeout?: number
max_retries?: number
priority?: number
weight?: number
max_concurrent?: number
rate_limit?: number
is_active?: boolean
config?: Record<string, any>
proxy?: ProxyConfig | null
@@ -52,16 +46,10 @@ export async function updateEndpoint(
endpointId: string,
data: Partial<{
base_url: string
custom_path: string
auth_type: string
auth_header: string
custom_path: string | null
headers: Record<string, string>
timeout: number
max_retries: number
priority: number
weight: number
max_concurrent: number
rate_limit: number
is_active: boolean
config: Record<string, any>
proxy: ProxyConfig | null
@@ -74,7 +62,7 @@ export async function updateEndpoint(
/**
* 删除 Endpoint
*/
export async function deleteEndpoint(endpointId: string): Promise<{ message: string; deleted_keys_count: number }> {
export async function deleteEndpoint(endpointId: string): Promise<{ message: string; affected_keys_count: number }> {
const response = await client.delete(`/api/admin/endpoints/${endpointId}`)
return response.data
}

View File

@@ -32,16 +32,21 @@ export async function getKeyHealth(keyId: string): Promise<HealthStatus> {
/**
* 恢复Key健康状态一键恢复重置健康度 + 关闭熔断器 + 取消自动禁用)
* @param keyId Key ID
* @param apiFormat 可选,指定 API 格式(如 CLAUDE、OPENAI不指定则恢复所有格式
*/
export async function recoverKeyHealth(keyId: string): Promise<{
export async function recoverKeyHealth(keyId: string, apiFormat?: string): Promise<{
message: string
details: {
api_format?: string
health_score: number
circuit_breaker_open: boolean
is_active: boolean
}
}> {
const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`)
const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`, null, {
params: apiFormat ? { api_format: apiFormat } : undefined
})
return response.data
}

View File

@@ -1,5 +1,5 @@
import client from '../client'
import type { EndpointAPIKey } from './types'
import type { EndpointAPIKey, AllowedModels } from './types'
/**
* 能力定义类型
@@ -49,67 +49,6 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
return response.data
}
/**
* 获取 Endpoint 的所有 Keys
*/
export async function getEndpointKeys(endpointId: string): Promise<EndpointAPIKey[]> {
const response = await client.get(`/api/admin/endpoints/${endpointId}/keys`)
return response.data
}
/**
* 为 Endpoint 添加 Key
*/
export async function addEndpointKey(
endpointId: string,
data: {
endpoint_id: string
api_key: string
name: string // 密钥名称(必填)
rate_multiplier?: number // 成本倍率(默认 1.0
internal_priority?: number // Endpoint 内部优先级(数字越小越优先)
max_concurrent?: number // 最大并发数(留空=自适应模式)
rate_limit?: number
daily_limit?: number
monthly_limit?: number
cache_ttl_minutes?: number // 缓存 TTL分钟0=禁用
max_probe_interval_minutes?: number // 熔断探测间隔(分钟)
allowed_models?: string[] // 允许使用的模型列表
capabilities?: Record<string, boolean> // 能力标签配置
note?: string // 备注说明(可选)
}
): Promise<EndpointAPIKey> {
const response = await client.post(`/api/admin/endpoints/${endpointId}/keys`, data)
return response.data
}
/**
* 更新 Endpoint Key
*/
export async function updateEndpointKey(
keyId: string,
data: Partial<{
api_key: string
name: string // 密钥名称
rate_multiplier: number // 成本倍率
internal_priority: number // Endpoint 内部优先级(提供商优先模式,数字越小越优先)
global_priority: number // 全局 Key 优先级(全局 Key 优先模式,数字越小越优先)
max_concurrent: number // 最大并发数(留空=自适应模式)
rate_limit: number
daily_limit: number
monthly_limit: number
cache_ttl_minutes: number // 缓存 TTL分钟0=禁用
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
allowed_models: string[] | null // 允许使用的模型列表null 表示允许所有
capabilities: Record<string, boolean> | null // 能力标签配置
is_active: boolean
note: string // 备注说明
}>
): Promise<EndpointAPIKey> {
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
return response.data
}
/**
* 获取完整的 API Key用于查看和复制
*/
@@ -119,22 +58,71 @@ export async function revealEndpointKey(keyId: string): Promise<{ api_key: strin
}
/**
* 删除 Endpoint Key
* 删除 Key
*/
export async function deleteEndpointKey(keyId: string): Promise<{ message: string }> {
const response = await client.delete(`/api/admin/endpoints/keys/${keyId}`)
return response.data
}
// ========== Provider 级别的 Keys API ==========
/**
* 批量更新 Endpoint Keys 的优先级(用于拖动排序)
* 获取 Provider 的所有 Keys
*/
export async function batchUpdateKeyPriority(
endpointId: string,
priorities: Array<{ key_id: string; internal_priority: number }>
): Promise<{ message: string; updated_count: number }> {
const response = await client.put(`/api/admin/endpoints/${endpointId}/keys/batch-priority`, {
priorities
})
export async function getProviderKeys(providerId: string): Promise<EndpointAPIKey[]> {
const response = await client.get(`/api/admin/endpoints/providers/${providerId}/keys`)
return response.data
}
/**
* 为 Provider 添加 Key
*/
export async function addProviderKey(
providerId: string,
data: {
api_formats: string[] // 支持的 API 格式列表(必填)
api_key: string
name: string
rate_multiplier?: number // 默认成本倍率
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority?: number
rpm_limit?: number | null // RPM 限制(留空=自适应模式)
cache_ttl_minutes?: number
max_probe_interval_minutes?: number
allowed_models?: AllowedModels
capabilities?: Record<string, boolean>
note?: string
}
): Promise<EndpointAPIKey> {
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data)
return response.data
}
/**
* 更新 Key
*/
export async function updateProviderKey(
keyId: string,
data: Partial<{
api_formats: string[] // 支持的 API 格式列表
api_key: string
name: string
rate_multiplier: number // 默认成本倍率
rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority: number
global_priority: number | null
rpm_limit: number | null // RPM 限制(留空=自适应模式)
cache_ttl_minutes: number
max_probe_interval_minutes: number
allowed_models: AllowedModels
capabilities: Record<string, boolean> | null
is_active: boolean
note: string
}>
): Promise<EndpointAPIKey> {
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
return response.data
}

View File

@@ -147,14 +147,26 @@ export async function queryProviderUpstreamModels(
/**
* 从上游提供商导入模型
* @param providerId 提供商 ID
* @param modelIds 模型 ID 列表
* @param options 可选配置
* @param options.tiered_pricing 阶梯计费配置
* @param options.price_per_request 按次计费价格
*/
export async function importModelsFromUpstream(
providerId: string,
modelIds: string[]
modelIds: string[],
options?: {
tiered_pricing?: object
price_per_request?: number
}
): Promise<ImportFromUpstreamResponse> {
const response = await client.post(
`/api/admin/providers/${providerId}/import-from-upstream`,
{ model_ids: modelIds }
{
model_ids: modelIds,
...options
}
)
return response.data
}

View File

@@ -1,5 +1,5 @@
import client from '../client'
import type { ProviderWithEndpointsSummary } from './types'
import type { ProviderWithEndpointsSummary, ProxyConfig } from './types'
/**
* 获取 Providers 摘要(包含 Endpoints 统计)
@@ -23,7 +23,7 @@ export async function getProvider(providerId: string): Promise<ProviderWithEndpo
export async function updateProvider(
providerId: string,
data: Partial<{
display_name: string
name: string
description: string
website: string
provider_priority: number
@@ -33,6 +33,10 @@ export async function updateProvider(
quota_last_reset_at: string // 周期开始时间
quota_expires_at: string
rpm_limit: number | null
// 请求配置(从 Endpoint 迁移)
timeout: number
max_retries: number
proxy: ProxyConfig | null
cache_ttl_minutes: number // 0表示不支持缓存>0表示支持缓存并设置TTL(分钟)
max_probe_interval_minutes: number
is_active: boolean
@@ -83,7 +87,6 @@ export interface TestModelResponse {
provider?: {
id: string
name: string
display_name: string
}
model?: string
}
@@ -92,4 +95,3 @@ export async function testModel(data: TestModelRequest): Promise<TestModelRespon
const response = await client.post('/api/admin/provider-query/test-model', data)
return response.data
}

View File

@@ -20,6 +20,38 @@ export const API_FORMAT_LABELS: Record<string, string> = {
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
}
// API 格式缩写映射(用于空间紧凑的显示场景)
export const API_FORMAT_SHORT: Record<string, string> = {
[API_FORMATS.OPENAI]: 'O',
[API_FORMATS.OPENAI_CLI]: 'OC',
[API_FORMATS.CLAUDE]: 'C',
[API_FORMATS.CLAUDE_CLI]: 'CC',
[API_FORMATS.GEMINI]: 'G',
[API_FORMATS.GEMINI_CLI]: 'GC',
}
// API 格式排序顺序(统一的显示顺序)
export const API_FORMAT_ORDER: string[] = [
API_FORMATS.OPENAI,
API_FORMATS.OPENAI_CLI,
API_FORMATS.CLAUDE,
API_FORMATS.CLAUDE_CLI,
API_FORMATS.GEMINI,
API_FORMATS.GEMINI_CLI,
]
// 工具函数:按标准顺序排序 API 格式数组
export function sortApiFormats(formats: string[]): string[] {
return [...formats].sort((a, b) => {
const aIdx = API_FORMAT_ORDER.indexOf(a)
const bIdx = API_FORMAT_ORDER.indexOf(b)
if (aIdx === -1 && bIdx === -1) return 0
if (aIdx === -1) return 1
if (bIdx === -1) return -1
return aIdx - bIdx
})
}
/**
* 代理配置类型
*/
@@ -37,18 +69,9 @@ export interface ProviderEndpoint {
api_format: string
base_url: string
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
auth_type: string
auth_header?: string
headers?: Record<string, string>
timeout: number
max_retries: number
priority: number
weight: number
max_concurrent?: number
rate_limit?: number
health_score: number
consecutive_failures: number
last_failure_at?: string
is_active: boolean
config?: Record<string, any>
proxy?: ProxyConfig | null
@@ -58,25 +81,55 @@ export interface ProviderEndpoint {
updated_at: string
}
/**
* 模型权限配置类型(支持简单列表和按格式字典两种模式)
*
* 使用示例:
* 1. 不限制(允许所有模型): null
* 2. 简单列表模式(所有 API 格式共享同一个白名单): ["gpt-4", "claude-3-opus"]
* 3. 按格式字典模式(不同 API 格式使用不同的白名单):
* { "OPENAI": ["gpt-4"], "CLAUDE": ["claude-3-opus"] }
*/
export type AllowedModels = string[] | Record<string, string[]> | null
// AllowedModels 类型守卫函数
export function isAllowedModelsList(value: AllowedModels): value is string[] {
return Array.isArray(value)
}
export function isAllowedModelsDict(value: AllowedModels): value is Record<string, string[]> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return false
}
// 验证所有值都是字符串数组
return Object.values(value).every(
(v) => Array.isArray(v) && v.every((item) => typeof item === 'string')
)
}
export interface EndpointAPIKey {
id: string
endpoint_id: string
provider_id: string
api_formats: string[] // 支持的 API 格式列表
api_key_masked: string
api_key_plain?: string | null
name: string // 密钥名称(必填,用于识别)
rate_multiplier: number // 成本倍率(真实成本 = 表面成本 × 倍率)
internal_priority: number // Endpoint 内部优先级
rate_multiplier: number // 默认成本倍率(真实成本 = 表面成本 × 倍率)
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率,如 {"CLAUDE": 1.0, "OPENAI": 0.8}
internal_priority: number // Key 内部优先级
global_priority?: number | null // 全局 Key 优先级
max_concurrent?: number
rate_limit?: number
daily_limit?: number
monthly_limit?: number
allowed_models?: string[] | null // 允许使用的模型列表null = 支持所有模型)
rpm_limit?: number | null // RPM 速率限制 (1-10000)null 表示自适应模式
allowed_models?: AllowedModels // 允许使用的模型列表null=不限制,列表=简单白名单,字典=按格式区分)
capabilities?: Record<string, boolean> | null // 能力标签配置(如 cache_1h, context_1m
// 缓存与熔断配置
cache_ttl_minutes: number // 缓存 TTL分钟0=禁用
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
// 按格式的健康度数据
health_by_format?: Record<string, FormatHealthData>
circuit_breaker_by_format?: Record<string, FormatCircuitBreakerData>
// 聚合字段(从 health_by_format 计算,用于列表显示)
health_score: number
circuit_breaker_open?: boolean
consecutive_failures: number
last_failure_at?: string
request_count: number
@@ -89,10 +142,10 @@ export interface EndpointAPIKey {
last_used_at?: string
created_at: string
updated_at: string
// 自适应并发字段
is_adaptive?: boolean // 是否为自适应模式(max_concurrent=NULL
effective_limit?: number // 当前有效限制(自适应使用学习值,固定使用配置值)
learned_max_concurrent?: number
// 自适应 RPM 字段
is_adaptive?: boolean // 是否为自适应模式(rpm_limit=NULL
effective_limit?: number // 当前有效 RPM 限制(自适应使用学习值,固定使用配置值)
learned_rpm_limit?: number // 学习到的 RPM 限制
// 滑动窗口利用率采样
utilization_samples?: Array<{ ts: number; util: number }> // 利用率采样窗口
last_probe_increase_at?: string // 上次探测性扩容时间
@@ -100,8 +153,7 @@ export interface EndpointAPIKey {
rpm_429_count?: number
last_429_at?: string
last_429_type?: string
// 熔断器字段(滑动窗口 + 半开模式)
circuit_breaker_open?: boolean
// 单格式场景的熔断器字段
circuit_breaker_open_at?: string
next_probe_at?: string
half_open_until?: string
@@ -110,17 +162,36 @@ export interface EndpointAPIKey {
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
}
// 按格式的健康度数据
export interface FormatHealthData {
health_score: number
error_rate: number
window_size: number
consecutive_failures: number
last_failure_at?: string | null
circuit_breaker: FormatCircuitBreakerData
}
// 按格式的熔断器数据
export interface FormatCircuitBreakerData {
open: boolean
open_at?: string | null
next_probe_at?: string | null
half_open_until?: string | null
half_open_successes: number
half_open_failures: number
}
export interface EndpointAPIKeyUpdate {
api_formats?: string[] // 支持的 API 格式列表
name?: string
api_key?: string // 仅在需要更新时提供
rate_multiplier?: number
rate_multiplier?: number // 默认成本倍率
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority?: number
global_priority?: number | null
max_concurrent?: number | null // null 表示切换为自适应模式
rate_limit?: number
daily_limit?: number
monthly_limit?: number
allowed_models?: string[] | null
rpm_limit?: number | null // RPM 速率限制 (1-10000)null 表示切换为自适应模式
allowed_models?: AllowedModels
capabilities?: Record<string, boolean> | null
cache_ttl_minutes?: number
max_probe_interval_minutes?: number
@@ -198,7 +269,6 @@ export interface PublicEndpointStatusMonitorResponse {
export interface ProviderWithEndpointsSummary {
id: string
name: string
display_name: string
description?: string
website?: string
provider_priority: number
@@ -208,9 +278,10 @@ export interface ProviderWithEndpointsSummary {
quota_reset_day?: number
quota_last_reset_at?: string // 当前周期开始时间
quota_expires_at?: string
rpm_limit?: number | null
rpm_used?: number
rpm_reset_at?: string
// 请求配置(从 Endpoint 迁移)
timeout?: number // 请求超时(秒)
max_retries?: number // 最大重试次数
proxy?: ProxyConfig | null // 代理配置
is_active: boolean
total_endpoints: number
active_endpoints: number
@@ -253,13 +324,10 @@ export interface HealthSummary {
}
}
export interface ConcurrencyStatus {
endpoint_id?: string
endpoint_current_concurrency: number
endpoint_max_concurrent?: number
key_id?: string
key_current_concurrency: number
key_max_concurrent?: number
export interface KeyRpmStatus {
key_id: string
current_rpm: number
rpm_limit?: number
}
export interface ProviderModelMapping {
@@ -361,7 +429,6 @@ export interface ModelPriceRange {
export interface ModelCatalogProviderDetail {
provider_id: string
provider_name: string
provider_display_name?: string | null
model_id?: string | null
target_model: string
input_price_per_1m?: number | null
@@ -534,10 +601,10 @@ export interface UpstreamModel {
*/
export interface ImportFromUpstreamSuccessItem {
model_id: string
global_model_id: string
global_model_name: string
provider_model_id: string
created_global_model: boolean
global_model_id?: string // 可选,未关联时为空字符串
global_model_name?: string // 可选,未关联时为空字符串
created_global_model: boolean // 始终为 false不再自动创建 GlobalModel
}
/**

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { CollapsibleContent, type CollapsibleContentProps } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps<CollapsibleContentProps & { class?: string }>()
</script>
<template>
<CollapsibleContent
v-bind="props"
:class="cn('overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down', props.class)"
>
<slot />
</CollapsibleContent>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { CollapsibleTrigger, type CollapsibleTriggerProps } from 'radix-vue'
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger v-bind="props" as-child>
<slot />
</CollapsibleTrigger>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { CollapsibleRoot, type CollapsibleRootEmits, type CollapsibleRootProps } from 'radix-vue'
import { useForwardPropsEmits } from 'radix-vue'
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot v-bind="forwarded">
<slot />
</CollapsibleRoot>
</template>

View File

@@ -65,3 +65,8 @@ export { default as RefreshButton } from './refresh-button.vue'
// Tooltip 提示系列
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'
// Collapsible 折叠系列
export { default as Collapsible } from './collapsible.vue'
export { default as CollapsibleTrigger } from './collapsible-trigger.vue'
export { default as CollapsibleContent } from './collapsible-content.vue'

View File

@@ -186,7 +186,7 @@
@click.stop
@change="toggleSelection('allowed_providers', provider.id)"
>
<span class="text-sm">{{ provider.display_name || provider.name }}</span>
<span class="text-sm">{{ provider.name }}</span>
</div>
<div
v-if="providers.length === 0"

View File

@@ -484,7 +484,7 @@
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="provider.is_active ? '活跃' : '停用'"
/>
<span class="font-medium truncate">{{ provider.display_name }}</span>
<span class="font-medium truncate">{{ provider.name }}</span>
</div>
</TableCell>
<TableCell class="py-3">
@@ -595,7 +595,7 @@
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
/>
<span class="font-medium truncate">{{ provider.display_name }}</span>
<span class="font-medium truncate">{{ provider.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button

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'

View File

@@ -289,14 +289,14 @@
/>
</div>
<!-- 错误信息卡片 -->
<!-- 响应客户端错误卡片 -->
<Card
v-if="detail.error_message"
class="border-red-200 dark:border-red-800"
>
<div class="p-4">
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
错误信息
响应客户端错误
</h4>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<p class="text-sm text-red-800 dark:text-red-300">
@@ -431,7 +431,7 @@
<TabsContent value="response-headers">
<JsonContent
:data="detail.response_headers"
:data="actualResponseHeaders"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
@@ -614,6 +614,25 @@ const tabs = [
{ name: 'metadata', label: '元数据' },
]
// 判断数据是否有实际内容(非空对象/数组)
function hasContent(data: unknown): boolean {
if (data === null || data === undefined) return false
if (typeof data === 'object') {
return Object.keys(data as object).length > 0
}
return true
}
// 获取实际的响应头(优先 client_response_headers回退到 response_headers
const actualResponseHeaders = computed(() => {
if (!detail.value) return null
// 优先返回客户端响应头,如果没有则回退到提供商响应头
if (hasContent(detail.value.client_response_headers)) {
return detail.value.client_response_headers
}
return detail.value.response_headers
})
// 根据实际数据决定显示哪些 Tab
const visibleTabs = computed(() => {
if (!detail.value) return []
@@ -621,15 +640,15 @@ const visibleTabs = computed(() => {
return tabs.filter(tab => {
switch (tab.name) {
case 'request-headers':
return detail.value!.request_headers && Object.keys(detail.value!.request_headers).length > 0
return hasContent(detail.value!.request_headers)
case 'request-body':
return detail.value!.request_body !== null && detail.value!.request_body !== undefined
return hasContent(detail.value!.request_body)
case 'response-headers':
return detail.value!.response_headers && Object.keys(detail.value!.response_headers).length > 0
return hasContent(actualResponseHeaders.value)
case 'response-body':
return detail.value!.response_body !== null && detail.value!.response_body !== undefined
return hasContent(detail.value!.response_body)
case 'metadata':
return detail.value!.metadata && Object.keys(detail.value!.metadata).length > 0
return hasContent(detail.value!.metadata)
default:
return false
}
@@ -775,7 +794,7 @@ function copyJsonToClipboard(tabName: string) {
data = detail.value.request_body
break
case 'response-headers':
data = detail.value.response_headers
data = actualResponseHeaders.value
break
case 'response-body':
data = detail.value.response_body

View File

@@ -252,7 +252,7 @@
@click.stop
@change="toggleSelection('allowed_providers', provider.id)"
>
<span class="text-sm">{{ provider.display_name || provider.name }}</span>
<span class="text-sm">{{ provider.name }}</span>
</div>
<div
v-if="providers.length === 0"

View File

@@ -424,8 +424,7 @@ export const MOCK_ADMIN_API_KEYS: AdminApiKeysResponse = {
export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
{
id: 'provider-001',
name: 'duck_coding_free',
display_name: 'DuckCodingFree',
name: 'DuckCodingFree',
description: '',
website: 'https://duckcoding.com',
provider_priority: 1,
@@ -451,8 +450,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
},
{
id: 'provider-002',
name: 'open_claude_code',
display_name: 'OpenClaudeCode',
name: 'OpenClaudeCode',
description: '',
website: 'https://www.openclaudecode.cn',
provider_priority: 2,
@@ -477,8 +475,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
},
{
id: 'provider-003',
name: '88_code',
display_name: '88Code',
name: '88Code',
description: '',
website: 'https://www.88code.org/',
provider_priority: 3,
@@ -503,8 +500,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
},
{
id: 'provider-004',
name: 'ikun_code',
display_name: 'IKunCode',
name: 'IKunCode',
description: '',
website: 'https://api.ikuncode.cc',
provider_priority: 4,
@@ -531,8 +527,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
},
{
id: 'provider-005',
name: 'duck_coding',
display_name: 'DuckCoding',
name: 'DuckCoding',
description: '',
website: 'https://duckcoding.com',
provider_priority: 5,
@@ -561,8 +556,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
},
{
id: 'provider-006',
name: 'privnode',
display_name: 'Privnode',
name: 'Privnode',
description: '',
website: 'https://privnode.com',
provider_priority: 6,
@@ -584,8 +578,7 @@ export const MOCK_PROVIDERS: ProviderWithEndpointsSummary[] = [
},
{
id: 'provider-007',
name: 'undying_api',
display_name: 'UndyingAPI',
name: 'UndyingAPI',
description: '',
website: 'https://vip.undyingapi.com',
provider_priority: 7,

View File

@@ -418,16 +418,16 @@ const MOCK_ALIASES = [
// Mock Endpoint Keys
const MOCK_ENDPOINT_KEYS = [
{ id: 'ekey-001', endpoint_id: 'ep-001', api_key_masked: 'sk-ant...abc1', name: 'Primary Key', rate_multiplier: 1.0, internal_priority: 1, health_score: 98, consecutive_failures: 0, request_count: 5000, success_count: 4950, error_count: 50, success_rate: 99, avg_response_time_ms: 1200, is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ekey-002', endpoint_id: 'ep-001', api_key_masked: 'sk-ant...def2', name: 'Backup Key', rate_multiplier: 1.0, internal_priority: 2, health_score: 95, consecutive_failures: 1, request_count: 2000, success_count: 1950, error_count: 50, success_rate: 97.5, avg_response_time_ms: 1350, is_active: true, created_at: '2024-02-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ekey-003', endpoint_id: 'ep-002', api_key_masked: 'sk-oai...ghi3', name: 'OpenAI Main', rate_multiplier: 1.0, internal_priority: 1, health_score: 97, consecutive_failures: 0, request_count: 3500, success_count: 3450, error_count: 50, success_rate: 98.6, avg_response_time_ms: 900, is_active: true, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() }
{ id: 'ekey-001', provider_id: 'provider-001', api_formats: ['CLAUDE'], api_key_masked: 'sk-ant...abc1', name: 'Primary Key', rate_multiplier: 1.0, internal_priority: 1, health_score: 0.98, consecutive_failures: 0, request_count: 5000, success_count: 4950, error_count: 50, success_rate: 0.99, avg_response_time_ms: 1200, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ekey-002', provider_id: 'provider-001', api_formats: ['CLAUDE'], api_key_masked: 'sk-ant...def2', name: 'Backup Key', rate_multiplier: 1.0, internal_priority: 2, health_score: 0.95, consecutive_failures: 1, request_count: 2000, success_count: 1950, error_count: 50, success_rate: 0.975, avg_response_time_ms: 1350, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-02-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ekey-003', provider_id: 'provider-002', api_formats: ['OPENAI'], api_key_masked: 'sk-oai...ghi3', name: 'OpenAI Main', rate_multiplier: 1.0, internal_priority: 1, health_score: 0.97, consecutive_failures: 0, request_count: 3500, success_count: 3450, error_count: 50, success_rate: 0.986, avg_response_time_ms: 900, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() }
]
// Mock Endpoints
const MOCK_ENDPOINTS = [
{ id: 'ep-001', provider_id: 'provider-001', provider_name: 'anthropic', api_format: 'claude', base_url: 'https://api.anthropic.com', auth_type: 'bearer', timeout: 120, max_retries: 2, priority: 100, weight: 100, health_score: 98, consecutive_failures: 0, is_active: true, total_keys: 2, active_keys: 2, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ep-002', provider_id: 'provider-002', provider_name: 'openai', api_format: 'openai', base_url: 'https://api.openai.com', auth_type: 'bearer', timeout: 60, max_retries: 2, priority: 90, weight: 100, health_score: 97, consecutive_failures: 0, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ep-003', provider_id: 'provider-003', provider_name: 'google', api_format: 'gemini', base_url: 'https://generativelanguage.googleapis.com', auth_type: 'api_key', timeout: 60, max_retries: 2, priority: 80, weight: 100, health_score: 96, consecutive_failures: 0, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() }
{ id: 'ep-001', provider_id: 'provider-001', provider_name: 'anthropic', api_format: 'CLAUDE', base_url: 'https://api.anthropic.com', timeout: 300, max_retries: 2, is_active: true, total_keys: 2, active_keys: 2, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ep-002', provider_id: 'provider-002', provider_name: 'openai', api_format: 'OPENAI', base_url: 'https://api.openai.com', timeout: 60, max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ep-003', provider_id: 'provider-003', provider_name: 'google', api_format: 'GEMINI', base_url: 'https://generativelanguage.googleapis.com', timeout: 60, max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() }
]
// Mock 能力定义
@@ -581,7 +581,6 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse(MOCK_PROVIDERS.map(p => ({
id: p.id,
name: p.name,
display_name: p.display_name,
is_active: p.is_active
})))
},
@@ -1222,13 +1221,8 @@ function generateMockEndpointsForProvider(providerId: string) {
base_url: format.includes('CLAUDE') ? 'https://api.anthropic.com' :
format.includes('OPENAI') ? 'https://api.openai.com' :
'https://generativelanguage.googleapis.com',
auth_type: format.includes('GEMINI') ? 'api_key' : 'bearer',
timeout: 120,
timeout: 300,
max_retries: 2,
priority: 100 - index * 10,
weight: 100,
health_score: healthDetail?.health_score ?? 1.0,
consecutive_failures: healthDetail?.health_score && healthDetail.health_score < 0.7 ? 2 : 0,
is_active: healthDetail?.is_active ?? true,
total_keys: Math.ceil(Math.random() * 3) + 1,
active_keys: Math.ceil(Math.random() * 2) + 1,
@@ -1238,11 +1232,16 @@ function generateMockEndpointsForProvider(providerId: string) {
})
}
// 为 endpoint 生成 keys
function generateMockKeysForEndpoint(endpointId: string, count: number = 2) {
// 为 provider 生成 keysKey 归属 Provider通过 api_formats 关联)
const PROVIDER_KEYS_CACHE: Record<string, any[]> = {}
function generateMockKeysForProvider(providerId: string, count: number = 2) {
const provider = MOCK_PROVIDERS.find(p => p.id === providerId)
const formats = provider?.api_formats || []
return Array.from({ length: count }, (_, i) => ({
id: `key-${endpointId}-${i + 1}`,
endpoint_id: endpointId,
id: `key-${providerId}-${i + 1}`,
provider_id: providerId,
api_formats: i === 0 ? formats : formats.slice(0, 1),
api_key_masked: `sk-***...${Math.random().toString(36).substring(2, 6)}`,
name: i === 0 ? 'Primary Key' : `Backup Key ${i}`,
rate_multiplier: 1.0,
@@ -1254,6 +1253,8 @@ function generateMockKeysForEndpoint(endpointId: string, count: number = 2) {
error_count: Math.floor(Math.random() * 100),
success_rate: 0.95 + Math.random() * 0.04, // 0.95-0.99
avg_response_time_ms: 800 + Math.floor(Math.random() * 600),
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
is_active: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: new Date().toISOString()
@@ -1463,29 +1464,63 @@ registerDynamicRoute('PUT', '/api/admin/endpoints/:endpointId', async (config, p
registerDynamicRoute('DELETE', '/api/admin/endpoints/:endpointId', async (_config, _params) => {
await delay()
requireAdmin()
return createMockResponse({ message: '删除成功(演示模式)' })
return createMockResponse({ message: '删除成功(演示模式)', affected_keys_count: 0 })
})
// Endpoint Keys 列表
registerDynamicRoute('GET', '/api/admin/endpoints/:endpointId/keys', async (_config, params) => {
// Provider Keys 列表
registerDynamicRoute('GET', '/api/admin/endpoints/providers/:providerId/keys', async (_config, params) => {
await delay()
requireAdmin()
const keys = generateMockKeysForEndpoint(params.endpointId, 2)
return createMockResponse(keys)
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
PROVIDER_KEYS_CACHE[params.providerId] = generateMockKeysForProvider(params.providerId, 2)
}
return createMockResponse(PROVIDER_KEYS_CACHE[params.providerId])
})
// 创建 Key
registerDynamicRoute('POST', '/api/admin/endpoints/:endpointId/keys', async (config, params) => {
// 为 Provider 创建 Key
registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/keys', async (config, params) => {
await delay()
requireAdmin()
const body = JSON.parse(config.data || '{}')
return createMockResponse({
const apiKeyPlain = body.api_key || 'sk-demo'
const masked = apiKeyPlain.length >= 12
? `${apiKeyPlain.slice(0, 8)}***${apiKeyPlain.slice(-4)}`
: 'sk-***...demo'
const newKey = {
id: `key-demo-${Date.now()}`,
endpoint_id: params.endpointId,
api_key_masked: 'sk-***...demo',
...body,
created_at: new Date().toISOString()
})
provider_id: params.providerId,
api_formats: body.api_formats || [],
api_key_masked: masked,
api_key_plain: null,
name: body.name || 'New Key',
note: body.note,
rate_multiplier: body.rate_multiplier ?? 1.0,
rate_multipliers: body.rate_multipliers ?? null,
internal_priority: body.internal_priority ?? 50,
global_priority: body.global_priority ?? null,
rpm_limit: body.rpm_limit ?? null,
allowed_models: body.allowed_models ?? null,
capabilities: body.capabilities ?? null,
cache_ttl_minutes: body.cache_ttl_minutes ?? 5,
max_probe_interval_minutes: body.max_probe_interval_minutes ?? 32,
health_score: 1.0,
consecutive_failures: 0,
request_count: 0,
success_count: 0,
error_count: 0,
success_rate: 0.0,
avg_response_time_ms: 0.0,
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
PROVIDER_KEYS_CACHE[params.providerId] = []
}
PROVIDER_KEYS_CACHE[params.providerId].push(newKey)
return createMockResponse(newKey)
})
// Key 更新
@@ -1503,6 +1538,50 @@ registerDynamicRoute('DELETE', '/api/admin/endpoints/keys/:keyId', async (_confi
return createMockResponse({ message: '删除成功(演示模式)' })
})
// Key Reveal
registerDynamicRoute('GET', '/api/admin/endpoints/keys/:keyId/reveal', async (_config, _params) => {
await delay()
requireAdmin()
return createMockResponse({ api_key: 'sk-demo-reveal' })
})
// Keys grouped by format
mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
await delay()
requireAdmin()
// 确保每个 provider 都有 key 数据
for (const provider of MOCK_PROVIDERS) {
if (!PROVIDER_KEYS_CACHE[provider.id]) {
PROVIDER_KEYS_CACHE[provider.id] = generateMockKeysForProvider(provider.id, 2)
}
}
const grouped: Record<string, any[]> = {}
for (const provider of MOCK_PROVIDERS) {
const endpoints = generateMockEndpointsForProvider(provider.id)
const baseUrlByFormat = Object.fromEntries(endpoints.map(e => [e.api_format, e.base_url]))
const keys = PROVIDER_KEYS_CACHE[provider.id] || []
for (const key of keys) {
const formats: string[] = key.api_formats || []
for (const fmt of formats) {
if (!grouped[fmt]) grouped[fmt] = []
grouped[fmt].push({
...key,
api_format: fmt,
provider_name: provider.name,
endpoint_base_url: baseUrlByFormat[fmt],
global_priority: key.global_priority ?? null,
circuit_breaker_open: false,
capabilities: [],
})
}
}
}
return createMockResponse(grouped)
}
// Provider Models 列表
registerDynamicRoute('GET', '/api/admin/providers/:providerId/models', async (_config, params) => {
await delay()

View File

@@ -20,7 +20,7 @@ interface ValidationError {
const fieldNameMap: Record<string, string> = {
'api_key': 'API 密钥',
'priority': '优先级',
'max_concurrent': '最大并发',
'rpm_limit': 'RPM 限制',
'rate_limit': '速率限制',
'daily_limit': '每日限制',
'monthly_limit': '每月限制',
@@ -44,7 +44,6 @@ const fieldNameMap: Record<string, string> = {
'monthly_quota_usd': '月度配额',
'quota_reset_day': '配额重置日',
'quota_expires_at': '配额过期时间',
'rpm_limit': 'RPM 限制',
'cache_ttl_minutes': '缓存 TTL',
'max_probe_interval_minutes': '最大探测间隔',
}
@@ -151,11 +150,18 @@ export function parseApiError(err: unknown, defaultMessage: string = '操作失
return '无法连接到服务器,请检查网络连接'
}
const detail = err.response?.data?.detail
const data = err.response?.data
// 1. 处理 {error: {type, message}} 格式ProxyException 返回格式)
if (data?.error?.message) {
return data.error.message
}
const detail = data?.detail
// 如果没有 detail 字段
if (!detail) {
return err.response?.data?.message || err.message || defaultMessage
return data?.message || err.message || defaultMessage
}
// 1. 处理 Pydantic 验证错误(数组格式)

View File

@@ -54,6 +54,57 @@ export function parseNumberInput(
return result
}
/**
* Parse number input value for nullable fields (like rpm_limit)
* Returns `null` when empty (to signal "use adaptive/default mode")
* Returns `undefined` when not provided (to signal "keep original value")
*
* @param value - Input value (string or number)
* @param options - Parse options
* @returns Parsed number, null (for empty/adaptive), or undefined
*/
export function parseNullableNumberInput(
value: string | number | null | undefined,
options: {
allowFloat?: boolean
min?: number
max?: number
} = {}
): number | null | undefined {
const { allowFloat = false, min, max } = options
// Empty string means "null" (adaptive mode)
if (value === '') {
return null
}
// null/undefined means "keep original value"
if (value === null || value === undefined) {
return undefined
}
// Parse the value
const num = typeof value === 'string'
? (allowFloat ? parseFloat(value) : parseInt(value, 10))
: value
// Handle NaN - treat as null (adaptive mode)
if (isNaN(num)) {
return null
}
// Apply min/max constraints
let result = num
if (min !== undefined && result < min) {
result = min
}
if (max !== undefined && result > max) {
result = max
}
return result
}
/**
* Create a handler function for number input with specific field
* Useful for creating inline handlers in templates

View File

@@ -530,9 +530,6 @@
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ provider.display_name || provider.name }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ provider.name }}
</p>
</div>
@@ -645,10 +642,7 @@
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ provider.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ provider.identifier }}
{{ provider.name }}
</p>
</div>
<Badge
@@ -679,7 +673,7 @@
<ProviderModelFormDialog
:open="editProviderDialogOpen"
:provider-id="editingProvider?.id || ''"
:provider-name="editingProvider?.display_name || ''"
:provider-name="editingProvider?.name || ''"
:editing-model="editingProviderModel"
@update:open="handleEditProviderDialogUpdate"
@saved="handleEditProviderSaved"
@@ -939,7 +933,7 @@ async function batchAddSelectedProviders() {
const errorMessages = result.errors
.map(e => {
const provider = providerOptions.value.find(p => p.id === e.provider_id)
const providerName = provider?.display_name || provider?.name || e.provider_id
const providerName = provider?.name || e.provider_id
return `${providerName}: ${e.error}`
})
.join('\n')
@@ -977,7 +971,7 @@ async function batchRemoveSelectedProviders() {
await deleteModel(providerId, provider.model_id)
successCount++
} catch (err: any) {
errors.push(`${provider.display_name}: ${parseApiError(err, '删除失败')}`)
errors.push(`${provider.name}: ${parseApiError(err, '删除失败')}`)
}
}
@@ -1088,8 +1082,7 @@ async function loadModelProviders(_globalModelId: string) {
selectedModelProviders.value = response.providers.map(p => ({
id: p.provider_id,
model_id: p.model_id,
display_name: p.provider_display_name || p.provider_name,
identifier: p.provider_name,
name: p.provider_name,
provider_type: 'API',
target_model: p.target_model,
is_active: p.is_active,
@@ -1219,7 +1212,7 @@ async function confirmDeleteProviderImplementation(provider: any) {
}
const confirmed = await confirmDanger(
`确定要删除 ${provider.display_name} 的模型关联吗?\n\n模型: ${provider.target_model}\n\n此操作不可恢复`,
`确定要删除 ${provider.name} 的模型关联吗?\n\n模型: ${provider.target_model}\n\n此操作不可恢复`,
'删除关联提供商'
)
if (!confirmed) return
@@ -1227,7 +1220,7 @@ async function confirmDeleteProviderImplementation(provider: any) {
try {
const { deleteModel } = await import('@/api/endpoints')
await deleteModel(provider.id, provider.model_id)
success(`已删除 ${provider.display_name} 的模型实现`)
success(`已删除 ${provider.name} 的模型实现`)
// 重新加载 Provider 列表
if (selectedModel.value) {
await loadModelProviders(selectedModel.value.id)

View File

@@ -134,10 +134,7 @@
@click="handleRowClick($event, provider.id)"
>
<TableCell class="py-3.5">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{{ provider.display_name }}</span>
<span class="text-xs text-muted-foreground/70 font-mono">{{ provider.name }}</span>
</div>
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
</TableCell>
<TableCell class="py-3.5">
<Badge
@@ -219,17 +216,10 @@
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
</div>
<div
v-if="rpmUsage(provider)"
class="flex items-center gap-1"
>
<span class="text-muted-foreground/70">RPM:</span>
<span class="font-medium text-foreground/80">{{ rpmUsage(provider) }}</span>
</div>
<div
v-if="provider.billing_type !== 'monthly_quota' && !rpmUsage(provider)"
v-else
class="text-muted-foreground/50"
>
无限制
按量付费
</div>
</div>
</TableCell>
@@ -304,7 +294,7 @@
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ provider.display_name }}</span>
<span class="font-medium text-foreground truncate">{{ provider.name }}</span>
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="text-xs shrink-0"
@@ -312,7 +302,6 @@
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<span class="text-xs text-muted-foreground/70 font-mono">{{ provider.name }}</span>
</div>
<div
class="flex items-center gap-0.5 shrink-0"
@@ -383,20 +372,17 @@
</span>
</div>
<!-- 第四行配额/限流 -->
<!-- 第四行配额 -->
<div
v-if="provider.billing_type === 'monthly_quota' || rpmUsage(provider)"
v-if="provider.billing_type === 'monthly_quota'"
class="flex items-center gap-3 text-xs text-muted-foreground"
>
<span v-if="provider.billing_type === 'monthly_quota'">
<span>
配额: <span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / ${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}
</span>
<span v-if="rpmUsage(provider)">
RPM: {{ rpmUsage(provider) }}
</span>
</div>
</div>
</div>
@@ -509,7 +495,7 @@ const filteredProviders = computed(() => {
if (searchQuery.value.trim()) {
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(p => {
const searchableText = `${p.display_name} ${p.name}`.toLowerCase()
const searchableText = `${p.name}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
@@ -525,7 +511,7 @@ const filteredProviders = computed(() => {
return a.provider_priority - b.provider_priority
}
// 3. 按名称排序
return a.display_name.localeCompare(b.display_name)
return a.name.localeCompare(b.name)
})
})
@@ -586,7 +572,10 @@ function sortEndpoints(endpoints: any[]) {
// 判断端点是否可用(有 key
function isEndpointAvailable(endpoint: any, _provider: ProviderWithEndpointsSummary): boolean {
// 检查端点是否有活跃的密钥
// 检查端点是否启用,以及是否有活跃的密钥
if (endpoint.is_active === false) {
return false
}
return (endpoint.active_keys ?? 0) > 0
}
@@ -639,21 +628,6 @@ function getQuotaUsedColorClass(provider: ProviderWithEndpointsSummary): string
return 'text-foreground'
}
function rpmUsage(provider: ProviderWithEndpointsSummary): string | null {
const rpmLimit = provider.rpm_limit
const rpmUsed = provider.rpm_used ?? 0
if (rpmLimit === null || rpmLimit === undefined) {
return rpmUsed > 0 ? `${rpmUsed}` : null
}
if (rpmLimit === 0) {
return '已完全禁止'
}
return `${rpmUsed} / ${rpmLimit}`
}
// 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
@@ -706,7 +680,7 @@ function handleProviderAdded() {
async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
const confirmed = await confirmDanger(
'删除提供商',
`确定要删除提供商 "${provider.display_name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复`
`确定要删除提供商 "${provider.name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复`
)
if (!confirmed) return

View File

@@ -511,7 +511,7 @@
端点: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.endpoints?.length || 0), 0) }}
</li>
<li>
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + p.endpoints?.reduce((s: number, e: any) => s + (e.keys?.length || 0), 0), 0) }}
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.api_keys?.length || 0), 0) }}
</li>
</ul>
</div>
@@ -1144,7 +1144,7 @@ function handleConfigFileSelect(event: Event) {
const data = JSON.parse(content) as ConfigExportData
// 验证版本
if (data.version !== '1.0') {
if (data.version !== '2.0') {
error(`不支持的配置版本: ${data.version}`)
return
}

View File

@@ -78,6 +78,20 @@ export default {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"collapsible-down": {
from: { height: "0" },
to: { height: "var(--radix-collapsible-content-height)" },
},
"collapsible-up": {
from: { height: "var(--radix-collapsible-content-height)" },
to: { height: "0" },
},
},
animation: {
"collapsible-down": "collapsible-down 0.2s ease-out",
"collapsible-up": "collapsible-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],