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