Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

View File

@@ -0,0 +1,387 @@
<template>
<Dialog
:model-value="open"
@update:model-value="$emit('update:open', $event)"
title="批量添加关联模型"
description="为提供商批量添加模型实现,提供商将自动继承模型的价格和能力,可在添加后单独修改"
:icon="Layers"
size="4xl"
>
<template #default>
<div class="space-y-4">
<!-- 提供商信息头部 -->
<div class="rounded-lg border bg-muted/30 p-4">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-lg">{{ providerName }}</p>
<p class="text-sm text-muted-foreground font-mono">{{ providerIdentifier }}</p>
</div>
<Badge variant="outline" class="text-xs">
当前 {{ existingModels.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">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">可添加</p>
<Button
v-if="availableModels.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllLeft"
>
{{ isAllLeftSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge variant="secondary" class="text-xs">
{{ availableModels.length }}
</Badge>
</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="availableModels.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<Layers class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">所有模型均已关联</p>
</div>
<div v-else class="p-2 space-y-1">
<div
v-for="model in availableModels"
:key="model.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors"
:class="selectedLeftIds.includes(model.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50 cursor-pointer'"
@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.display_name }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
</div>
<Badge
:variant="model.is_active ? 'outline' : 'secondary'"
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs shrink-0"
>
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</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 && !submittingAdd ? 'border-primary' : ''"
:disabled="selectedLeftIds.length === 0 || submittingAdd"
@click="batchAddSelected"
title="添加选中"
>
<Loader2 v-if="submittingAdd" class="w-4 h-4 animate-spin" />
<ChevronRight v-else class="w-6 h-6 stroke-[3]" :class="selectedLeftIds.length > 0 && !submittingAdd ? 'text-primary' : ''" />
</Button>
<Button
variant="outline"
size="sm"
class="w-9 h-8"
:class="selectedRightIds.length > 0 && !submittingRemove ? 'border-primary' : ''"
:disabled="selectedRightIds.length === 0 || submittingRemove"
@click="batchRemoveSelected"
title="移除选中"
>
<Loader2 v-if="submittingRemove" class="w-4 h-4 animate-spin" />
<ChevronLeft v-else class="w-6 h-6 stroke-[3]" :class="selectedRightIds.length > 0 && !submittingRemove ? 'text-primary' : ''" />
</Button>
</div>
<!-- 右侧已添加的模型 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">已添加</p>
<Button
v-if="existingModels.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllRight"
>
{{ isAllRightSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge variant="secondary" class="text-xs">
{{ existingModels.length }}
</Badge>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div v-if="existingModels.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<Layers class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">暂无关联模型</p>
</div>
<div v-else class="p-2 space-y-1">
<div
v-for="model in existingModels"
:key="'existing-' + model.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
:class="selectedRightIds.includes(model.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50'"
@click="toggleRightSelection(model.id)"
>
<Checkbox
:checked="selectedRightIds.includes(model.id)"
@update:checked="toggleRightSelection(model.id)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{{ model.global_model_display_name || model.provider_model_name }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.provider_model_name }}</p>
</div>
<Badge
:variant="model.is_active ? 'outline' : 'secondary'"
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs shrink-0"
>
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<Button
variant="outline"
@click="$emit('update:open', false)"
>
关闭
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Layers, Loader2, ChevronRight, ChevronLeft } from 'lucide-vue-next'
import Dialog from '@/components/ui/dialog/Dialog.vue'
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 } from '@/utils/errorParser'
import {
getGlobalModels,
type GlobalModelResponse
} from '@/api/endpoints/global-models'
import {
getProviderModels,
batchAssignModelsToProvider,
deleteModel,
type Model
} from '@/api/endpoints'
const props = defineProps<{
open: boolean
providerId: string
providerName: string
providerIdentifier: string
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'changed': []
}>()
const { error: showError, success } = useToast()
// 状态
const loadingGlobalModels = ref(false)
const submittingAdd = ref(false)
const submittingRemove = ref(false)
// 数据
const allGlobalModels = ref<GlobalModelResponse[]>([])
const existingModels = ref<Model[]>([])
// 选择状态
const selectedLeftIds = ref<string[]>([])
const selectedRightIds = ref<string[]>([])
// 计算可添加的模型(排除已关联的)
const availableModels = computed(() => {
const existingGlobalModelIds = new Set(
existingModels.value
.filter(m => m.global_model_id)
.map(m => m.global_model_id)
)
return allGlobalModels.value.filter(m => !existingGlobalModelIds.has(m.id))
})
// 全选状态
const isAllLeftSelected = computed(() =>
availableModels.value.length > 0 &&
selectedLeftIds.value.length === availableModels.value.length
)
const isAllRightSelected = computed(() =>
existingModels.value.length > 0 &&
selectedRightIds.value.length === existingModels.value.length
)
// 监听打开状态
watch(() => props.open, async (isOpen) => {
if (isOpen && props.providerId) {
await loadData()
} else {
// 重置状态
selectedLeftIds.value = []
selectedRightIds.value = []
}
})
// 加载数据
async function loadData() {
await Promise.all([loadGlobalModels(), loadExistingModels()])
}
// 加载全局模型列表
async function loadGlobalModels() {
try {
loadingGlobalModels.value = true
const response = await getGlobalModels({ limit: 1000 })
allGlobalModels.value = response.models
} catch (err: any) {
showError(parseApiError(err, '加载全局模型失败'), '错误')
} finally {
loadingGlobalModels.value = false
}
}
// 加载已关联的模型
async function loadExistingModels() {
try {
existingModels.value = await getProviderModels(props.providerId)
} catch (err: any) {
showError(parseApiError(err, '加载已关联模型失败'), '错误')
}
}
// 切换左侧选择
function toggleLeftSelection(id: string) {
const index = selectedLeftIds.value.indexOf(id)
if (index === -1) {
selectedLeftIds.value.push(id)
} else {
selectedLeftIds.value.splice(index, 1)
}
}
// 切换右侧选择
function toggleRightSelection(id: string) {
const index = selectedRightIds.value.indexOf(id)
if (index === -1) {
selectedRightIds.value.push(id)
} else {
selectedRightIds.value.splice(index, 1)
}
}
// 全选/取消全选左侧
function toggleSelectAllLeft() {
if (isAllLeftSelected.value) {
selectedLeftIds.value = []
} else {
selectedLeftIds.value = availableModels.value.map(m => m.id)
}
}
// 全选/取消全选右侧
function toggleSelectAllRight() {
if (isAllRightSelected.value) {
selectedRightIds.value = []
} else {
selectedRightIds.value = existingModels.value.map(m => m.id)
}
}
// 批量添加选中的模型
async function batchAddSelected() {
if (selectedLeftIds.value.length === 0) return
try {
submittingAdd.value = true
const result = await batchAssignModelsToProvider(props.providerId, selectedLeftIds.value)
if (result.success.length > 0) {
success(`成功添加 ${result.success.length} 个模型`)
}
if (result.errors.length > 0) {
const errorMessages = result.errors.map(e => e.error).join(', ')
showError(`部分模型添加失败: ${errorMessages}`, '警告')
}
selectedLeftIds.value = []
await loadExistingModels()
emit('changed')
} catch (err: any) {
showError(parseApiError(err, '批量添加失败'), '错误')
} finally {
submittingAdd.value = false
}
}
// 批量移除选中的模型
async function batchRemoveSelected() {
if (selectedRightIds.value.length === 0) return
try {
submittingRemove.value = true
let successCount = 0
const errors: string[] = []
for (const modelId of selectedRightIds.value) {
try {
await deleteModel(props.providerId, modelId)
successCount++
} catch (err: any) {
errors.push(parseApiError(err, '删除失败'))
}
}
if (successCount > 0) {
success(`成功移除 ${successCount} 个模型`)
}
if (errors.length > 0) {
showError(`部分模型移除失败: ${errors.join(', ')}`, '警告')
}
selectedRightIds.value = []
await loadExistingModels()
emit('changed')
} catch (err: any) {
showError(parseApiError(err, '批量移除失败'), '错误')
} finally {
submittingRemove.value = false
}
}
</script>

View File

@@ -0,0 +1,315 @@
<template>
<Dialog
:model-value="internalOpen"
:title="isEditMode ? '编辑 API 端点' : '添加 API 端点'"
:description="isEditMode ? `修改 ${provider?.display_name} 的端点配置` : '为提供商添加新的 API 端点'"
:icon="isEditMode ? SquarePen : Link"
size="xl"
@update:model-value="handleDialogUpdate"
>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- API 配置 -->
<div class="space-y-4">
<h3 v-if="isEditMode" class="text-sm font-medium">API 配置</h3>
<div class="grid grid-cols-2 gap-4">
<!-- API 格式 -->
<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>
<Select v-model="form.api_format" v-model:open="selectOpen" required>
<SelectTrigger>
<SelectValue placeholder="请选择 API 格式" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="format in apiFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
</template>
</div>
<!-- API URL -->
<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>
<!-- 自定义路径 -->
<div class="space-y-2">
<Label for="custom_path">自定义请求路径可选</Label>
<Input
id="custom_path"
v-model="form.custom_path"
:placeholder="defaultPathPlaceholder"
/>
</div>
</div>
<!-- 请求配置 -->
<div class="space-y-4">
<h3 class="text-sm font-medium">请求配置</h3>
<div class="grid grid-cols-3 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>
<div class="grid grid-cols-2 gap-4">
<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)"
/>
</div>
</div>
</div>
</form>
<template #footer>
<Button
@click="handleCancel"
type="button"
variant="outline"
:disabled="loading"
>
取消
</Button>
<Button
@click="handleSubmit"
:disabled="loading || !form.base_url || (!isEditMode && !form.api_format)"
>
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : '创建') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { Link, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseNumberInput } from '@/utils/form'
import {
createEndpoint,
updateEndpoint,
type ProviderEndpoint,
type ProviderWithEndpointsSummary
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
const props = defineProps<{
modelValue: boolean
provider: ProviderWithEndpointsSummary | null
endpoint?: ProviderEndpoint | null // 编辑模式时传入
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'endpoint-created': []
'endpoint-updated': []
}>()
const { success, error: showError } = useToast()
const loading = ref(false)
const selectOpen = ref(false)
// 内部状态
const internalOpen = computed(() => props.modelValue)
// 表单数据
const form = ref({
api_format: '',
base_url: '',
custom_path: '',
timeout: 300,
max_retries: 3,
max_concurrent: undefined as number | undefined,
rate_limit: undefined as number | undefined,
is_active: true
})
// API 格式列表
const apiFormats = ref<Array<{ value: string; label: string; default_path: string; aliases: string[] }>>([])
// 加载API格式列表
const loadApiFormats = async () => {
try {
const response = await adminApi.getApiFormats()
apiFormats.value = response.formats
} catch (error) {
console.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}`
})
// 组件挂载时加载API格式
onMounted(() => {
loadApiFormats()
})
// 重置表单
function resetForm() {
form.value = {
api_format: '',
base_url: '',
custom_path: '',
timeout: 300,
max_retries: 3,
max_concurrent: undefined,
rate_limit: undefined,
is_active: true
}
}
// 加载端点数据(编辑模式)
function loadEndpointData() {
if (!props.endpoint) return
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
}
}
// 使用 useFormDialog 统一处理对话框逻辑
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
isOpen: () => props.modelValue,
entity: () => props.endpoint,
isLoading: loading,
onClose: () => emit('update:modelValue', false),
loadData: loadEndpointData,
resetForm,
})
// 提交表单
const handleSubmit = async () => {
if (!props.provider && !props.endpoint) return
loading.value = true
try {
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
})
success('端点已更新', '保存成功')
emit('endpoint-updated')
} else if (props.provider) {
// 创建端点
await createEndpoint(props.provider.id, {
provider_id: props.provider.id,
api_format: form.value.api_format,
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
})
success('端点创建成功', '成功')
emit('endpoint-created')
resetForm()
}
emit('update:modelValue', false)
} catch (error: any) {
const action = isEditMode.value ? '更新' : '创建'
showError(error.response?.data?.detail || `${action}端点失败`, '错误')
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,256 @@
<template>
<div class="w-full space-y-1">
<!-- 时间线 -->
<div class="flex items-center gap-px h-6 w-full">
<TooltipProvider v-for="(segment, index) in segments" :key="index" :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<div
class="flex-1 h-full rounded-sm transition-all duration-150 cursor-pointer hover:scale-y-110 hover:brightness-110"
:class="segment.color"
></div>
</TooltipTrigger>
<TooltipContent side="top" :side-offset="8" class="max-w-xs">
<div class="text-xs whitespace-pre-line">{{ segment.tooltip }}</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- 时间标签 -->
<div class="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{{ earliestTime }}</span>
<span>{{ latestTime }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { EndpointStatusMonitor, EndpointHealthEvent, PublicEndpointStatusMonitor, PublicHealthEvent } from '@/api/endpoints'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
// 组件同时支持管理员端和用户端的监控数据类型
// - EndpointStatusMonitor: 管理员端,包含 provider_count, key_count 等敏感信息
// - PublicEndpointStatusMonitor: 用户端,不含敏感信息
const props = defineProps<{
monitor?: EndpointStatusMonitor | PublicEndpointStatusMonitor | null
segmentCount?: number
lookbackHours?: number
}>()
// 固定格子数量,将实际事件按时间均匀分布到格子中
const GRID_COUNT = 100
const segments = computed(() => {
const gridCount = props.segmentCount ?? GRID_COUNT
const lookbackHours = props.lookbackHours ?? 6
const usageTimeline = Array.isArray(props.monitor?.timeline)
? props.monitor?.timeline ?? []
: []
if (usageTimeline.length > 0) {
return buildUsageTimelineSegments(
usageTimeline,
props.monitor?.time_range_start ?? null,
props.monitor?.time_range_end ?? null,
lookbackHours
)
}
const events = props.monitor?.events ?? []
// 无数据时显示空白格子
if (events.length === 0) {
return Array.from({ length: gridCount }, () => ({
color: 'bg-gray-300 dark:bg-gray-600',
tooltip: '暂无请求记录'
}))
}
// 计算时间范围:使用 UTC 时间戳避免时区问题
const nowUtc = Date.now()
const startTimeUtc = nowUtc - lookbackHours * 60 * 60 * 1000
const timeRange = lookbackHours * 60 * 60 * 1000
const timePerGrid = timeRange / gridCount
const gridEvents: Array<Array<EndpointHealthEvent | PublicHealthEvent>> = Array.from({ length: gridCount }, () => [])
for (const event of events) {
const eventTime = new Date(event.timestamp).getTime()
const gridIndex = Math.floor((eventTime - startTimeUtc) / timePerGrid)
if (gridIndex >= 0 && gridIndex < gridCount) {
gridEvents[gridIndex].push(event)
}
}
const result: Array<{ color: string; tooltip: string }> = []
for (let i = 0; i < gridCount; i++) {
const cellEvents = gridEvents[i]
const cellStartTime = new Date(startTimeUtc + i * timePerGrid)
const cellEndTime = new Date(startTimeUtc + (i + 1) * timePerGrid)
if (cellEvents.length === 0) {
result.push({
color: 'bg-gray-300 dark:bg-gray-600',
tooltip: `${formatTimestamp(cellStartTime.toISOString())} - ${formatTimestamp(cellEndTime.toISOString())}\n暂无请求记录`
})
continue
}
if (cellEvents.length === 1) {
result.push({
color: getStatusColor(cellEvents[0].status),
tooltip: buildTooltip(cellEvents[0])
})
continue
}
const successCount = cellEvents.filter(e => e.status === 'success').length
const failedCount = cellEvents.filter(e => e.status === 'failed').length
const skippedCount = cellEvents.filter(e => e.status === 'skipped').length
const total = cellEvents.length
let color: string
if (failedCount > 0) {
const failRate = failedCount / total
color = failRate > 0.5 ? 'bg-red-500' : 'bg-red-400/80'
} else if (successCount > 0) {
const successRate = successCount / total
color = successRate > 0.7 ? 'bg-green-500/80' : 'bg-green-400/80'
} else if (skippedCount > 0) {
color = 'bg-amber-400/80'
} else {
color = 'bg-gray-300 dark:bg-gray-600'
}
const firstTime = formatTimestamp(cellEvents[0]?.timestamp)
const lastTime = formatTimestamp(cellEvents[cellEvents.length - 1]?.timestamp)
const tooltip = `${firstTime} - ${lastTime}\n共 ${total} 次请求\n成功: ${successCount}, 失败: ${failedCount}, 跳过: ${skippedCount}`
result.push({ color, tooltip })
}
return result
})
function getStatusColor(status: string) {
switch (status) {
case 'success':
return 'bg-green-500/80 dark:bg-green-400/90'
case 'failed':
return 'bg-red-500/80 dark:bg-red-400/90'
case 'skipped':
return 'bg-amber-400/80 dark:bg-amber-300/80'
case 'started':
return 'bg-blue-400/80 dark:bg-blue-300/80'
default:
return 'bg-muted/50 dark:bg-muted/20'
}
}
function buildTooltip(event: EndpointHealthEvent | PublicHealthEvent) {
const time = formatTimestamp(event.timestamp)
const statusText = getStatusText(event.status)
const latency = event.latency_ms ? `${event.latency_ms}ms` : ''
const code = event.status_code ? `${event.status_code}` : ''
const error = event.error_type ? `${event.error_type}` : ''
return `${time} ${statusText}${latency}${code}${error}`
}
function getStatusText(status: string) {
switch (status) {
case 'success':
return '成功'
case 'failed':
return '失败'
case 'skipped':
return '跳过'
case 'started':
return '执行中'
default:
return '未知'
}
}
function formatTimestamp(timestamp?: string | null) {
if (!timestamp) return '未知时间'
const date = new Date(timestamp)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 计算时间范围显示
const earliestTime = computed(() => {
const explicitStart =
(props.monitor as (EndpointStatusMonitor | PublicEndpointStatusMonitor | null))?.time_range_start
if (explicitStart) return formatTimestamp(explicitStart)
const lookbackHours = props.lookbackHours ?? 6
const startTime = new Date(Date.now() - lookbackHours * 60 * 60 * 1000)
return formatTimestamp(startTime.toISOString())
})
const latestTime = computed(() => {
const explicitEnd =
(props.monitor as (EndpointStatusMonitor | PublicEndpointStatusMonitor | null))?.time_range_end
if (explicitEnd) return formatTimestamp(explicitEnd)
return formatTimestamp(new Date().toISOString())
})
function buildUsageTimelineSegments(
statuses: string[],
timeRangeStart: string | null,
timeRangeEnd: string | null,
lookbackHours: number
) {
const gridCount = statuses.length
const endTime = timeRangeEnd ? new Date(timeRangeEnd).getTime() : Date.now()
const startTime = timeRangeStart
? new Date(timeRangeStart).getTime()
: endTime - lookbackHours * 60 * 60 * 1000
const safeRange = Math.max(endTime - startTime, 1)
const interval = safeRange / gridCount
return statuses.map((status, index) => {
const cellStart = new Date(startTime + index * interval)
const cellEnd = new Date(startTime + (index + 1) * interval)
return {
color: getHealthTimelineColor(status),
tooltip: `${formatTimestamp(cellStart.toISOString())} - ${formatTimestamp(
cellEnd.toISOString()
)}\n状态${getHealthTimelineLabel(status)}`
}
})
}
function getHealthTimelineColor(status: string) {
switch (status) {
case 'healthy':
return 'bg-green-500/80 dark:bg-green-400/90'
case 'warning':
return 'bg-amber-400/80 dark:bg-amber-300/80'
case 'unhealthy':
return 'bg-red-500/80 dark:bg-red-400/90'
default:
return 'bg-gray-300 dark:bg-gray-600'
}
}
function getHealthTimelineLabel(status: string) {
switch (status) {
case 'healthy':
return '健康'
case 'warning':
return '警告'
case 'unhealthy':
return '异常'
default:
return '未知'
}
}
</script>

View File

@@ -0,0 +1,164 @@
<template>
<Card variant="default" class="overflow-hidden">
<!-- 标题和筛选器 -->
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<h3 class="text-base font-semibold">{{ title }}</h3>
<div class="flex items-center gap-3">
<Label class="text-xs text-muted-foreground">回溯时间</Label>
<Select v-model="lookbackHours" v-model:open="selectOpen">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 小时</SelectItem>
<SelectItem value="6">6 小时</SelectItem>
<SelectItem value="12">12 小时</SelectItem>
<SelectItem value="24">24 小时</SelectItem>
<SelectItem value="48">48 小时</SelectItem>
</SelectContent>
</Select>
<RefreshButton :loading="loading" @click="refreshData" />
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="p-6">
<div v-if="loadingMonitors" class="flex items-center justify-center py-12">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-muted-foreground">加载中...</span>
</div>
<div v-else-if="monitors.length === 0" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Activity class="w-12 h-12 mb-3 opacity-30" />
<p>暂无健康监控数据</p>
<p class="text-xs mt-1">端点尚未产生请求记录</p>
</div>
<div v-else class="space-y-3">
<div
v-for="monitor in monitors"
:key="monitor.api_format"
class="border border-border/60 rounded-lg p-4 hover:border-primary/50 transition-colors"
>
<!-- 左右结构布局 -->
<div class="flex gap-6 items-center">
<!-- 左侧信息区域 -->
<div class="w-44 flex-shrink-0 space-y-1.5">
<!-- API 格式标签和成功率 -->
<div class="flex items-center gap-2">
<Badge variant="outline" class="font-mono text-xs">
{{ monitor.api_format }}
</Badge>
<Badge
v-if="monitor.total_attempts > 0"
:variant="getSuccessRateVariant(monitor.success_rate)"
class="text-xs"
>
{{ (monitor.success_rate * 100).toFixed(0) }}%
</Badge>
</div>
<!-- 提供商信息仅管理员可见 -->
<div v-if="showProviderInfo && 'provider_count' in monitor" class="text-xs text-muted-foreground">
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
</div>
</div>
<!-- 右侧时间线区域 -->
<div class="flex-1 min-w-0 flex justify-end">
<div class="w-full max-w-5xl">
<EndpointHealthTimeline
:monitor="monitor"
:lookback-hours="parseInt(lookbackHours)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { Activity, Loader2 } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import EndpointHealthTimeline from './EndpointHealthTimeline.vue'
import { getEndpointStatusMonitor, getPublicEndpointStatusMonitor } from '@/api/endpoints/health'
import type { EndpointStatusMonitor, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
import { useToast } from '@/composables/useToast'
const props = withDefaults(defineProps<{
title?: string
isAdmin?: boolean
showProviderInfo?: boolean
}>(), {
title: '健康监控',
isAdmin: false,
showProviderInfo: false
})
const { error: showError } = useToast()
const loading = ref(false)
const loadingMonitors = ref(false)
const monitors = ref<(EndpointStatusMonitor | PublicEndpointStatusMonitor)[]>([])
const lookbackHours = ref('6')
const selectOpen = ref(false)
async function loadMonitors() {
loadingMonitors.value = true
try {
const params = {
lookback_hours: parseInt(lookbackHours.value),
per_format_limit: 100
}
if (props.isAdmin) {
const data = await getEndpointStatusMonitor(params)
monitors.value = data.formats || []
} else {
const data = await getPublicEndpointStatusMonitor(params)
monitors.value = data.formats || []
}
} catch (err: any) {
showError(err.response?.data?.detail || '加载健康监控数据失败', '错误')
} finally {
loadingMonitors.value = false
}
}
async function refreshData() {
loading.value = true
try {
await loadMonitors()
} finally {
loading.value = false
}
}
function getSuccessRateVariant(rate: number): 'default' | 'secondary' | 'destructive' | 'outline' {
if (rate >= 0.95) return 'default'
if (rate >= 0.8) return 'secondary'
return 'destructive'
}
watch(lookbackHours, () => {
loadMonitors()
})
onMounted(() => {
refreshData()
})
</script>

View File

@@ -0,0 +1,295 @@
<template>
<Dialog
:model-value="isOpen"
title="配置允许的模型"
description="选择该 API Key 允许访问的模型,留空则允许访问所有模型"
:icon="Settings2"
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"
>
{{ getModelLabel(modelName) }}
<button
class="ml-0.5 hover:text-destructive focus:outline-none"
@click.stop="toggleModel(modelName, false)"
>
&times;
</button>
</Badge>
</div>
</div>
<!-- 模型列表区域 -->
<div class="space-y-2">
<div class="flex items-center justify-between px-1">
<div class="text-xs font-medium text-muted-foreground">可选模型列表</div>
<div v-if="!loadingModels && availableModels.length > 0" class="text-[10px] text-muted-foreground/60">
{{ availableModels.length }} 个模型
</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"></div>
<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
v-for="model in availableModels"
:key="model.global_model_name"
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)
? '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))"
>
<!-- Checkbox -->
<Checkbox
:checked="selectedModels.includes(model.global_model_name)"
class="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
@click.stop
@update:checked="checked => toggleModel(model.global_model_name, 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) }}
</span>
</div>
<div class="text-[11px] text-muted-foreground/60 font-mono truncate mt-0.5">
{{ model.global_model_name }}
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 w-full pt-2">
<Button @click="handleCancel" variant="outline" class="h-9">取消</Button>
<Button @click="handleSave" :disabled="saving" class="h-9 min-w-[80px]">
<Loader2 v-if="saving" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
{{ saving ? '保存中' : '保存配置' }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Box, Loader2, Settings2 } 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 } from '@/utils/errorParser'
import {
updateEndpointKey,
getProviderAvailableSourceModels,
type EndpointAPIKey,
type ProviderAvailableSourceModel
} from '@/api/endpoints'
const props = defineProps<{
open: boolean
apiKey: EndpointAPIKey | null
providerId: string | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const { success, error: showError } = useToast()
const isOpen = computed(() => props.open)
const saving = ref(false)
const loadingModels = ref(false)
const availableModels = ref<ProviderAvailableSourceModel[]>([])
const selectedModels = ref<string[]>([])
const initialModels = ref<string[]>([])
// 监听对话框打开
watch(() => props.open, (open) => {
if (open) {
loadData()
}
})
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() {
selectedModels.value = []
}
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])
}
function handleDialogUpdate(value: boolean) {
if (!value) {
emit('close')
}
}
function handleCancel() {
emit('close')
}
async function handleSave() {
if (!props.apiKey) return
// 检查是否有变化
const hasChanged = !areArraysEqual(selectedModels.value, initialModels.value)
if (!hasChanged) {
emit('close')
return
}
saving.value = true
try {
await updateEndpointKey(props.apiKey.id, {
// 空数组时发送 null表示允许所有模型
allowed_models: selectedModels.value.length > 0 ? [...selectedModels.value] : null
})
success('允许的模型已更新', '成功')
emit('saved')
emit('close')
} catch (err: any) {
const errorMessage = parseApiError(err, '保存失败')
showError(errorMessage, '错误')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.2);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.4);
}
</style>

View File

@@ -0,0 +1,459 @@
<template>
<Dialog
:model-value="isOpen"
:title="isEditMode ? '编辑密钥' : '添加密钥'"
:description="isEditMode ? '修改 API 密钥配置' : '为端点添加新的 API 密钥'"
:icon="isEditMode ? SquarePen : Key"
size="2xl"
@update:model-value="handleDialogUpdate"
>
<form @submit.prevent="handleSave" class="space-y-5" autocomplete="off">
<!-- 基本信息 -->
<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"
:name="keyNameFieldName"
v-model="form.name"
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>
<div>
<Label :for="apiKeyInputId">API 密钥 {{ editingKey ? '' : '*' }}</Label>
<Input
:id="apiKeyInputId"
:name="apiKeyFieldName"
v-model="form.api_key"
:type="apiKeyInputType"
:required="!editingKey"
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
:class="getApiKeyInputClass()"
autocomplete="new-password"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
@focus="apiKeyFocused = true"
@blur="apiKeyFocused = form.api_key.trim().length > 0"
/>
<p v-if="apiKeyError" class="text-xs text-destructive mt-1">
{{ apiKeyError }}
</p>
<p v-else-if="editingKey" class="text-xs text-muted-foreground mt-1">
留空表示不修改输入新值则覆盖
</p>
</div>
<div>
<Label for="note">备注</Label>
<Input
id="note"
v-model="form.note"
placeholder="可选的备注信息"
/>
</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
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"
>
<input
type="checkbox"
:checked="form.capabilities[cap.name] || false"
@change="form.capabilities[cap.name] = !form.capabilities[cap.name]"
class="rounded"
/>
<span>{{ cap.display_name }}</span>
</label>
</div>
</div>
</form>
<template #footer>
<Button @click="handleCancel" variant="outline">取消</Button>
<Button @click="handleSave" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
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 {
addEndpointKey,
updateEndpointKey,
getAllCapabilities,
type EndpointAPIKey,
type ProviderEndpoint,
type CapabilityDefinition
} from '@/api/endpoints'
const props = defineProps<{
open: boolean
endpoint: ProviderEndpoint | null
editingKey: EndpointAPIKey | null
providerId: string | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const { success, error: showError } = useToast()
const isOpen = computed(() => props.open)
const saving = ref(false)
const formNonce = ref(createFieldNonce())
const keyNameInputId = computed(() => `key-name-${formNonce.value}`)
const apiKeyInputId = computed(() => `api-key-${formNonce.value}`)
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)
const apiKeyFocused = ref(false)
const apiKeyInputType = computed(() =>
apiKeyFocused.value || form.value.api_key.trim().length > 0 ? 'password' : 'text'
)
// 可用的能力列表
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,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {} as Record<string, boolean>
})
// 加载能力列表
async function loadCapabilities() {
try {
availableCapabilities.value = await getAllCapabilities()
} catch (err) {
console.error('Failed to load capabilities:', err)
}
}
onMounted(() => {
loadCapabilities()
})
// API 密钥输入框样式计算
function getApiKeyInputClass(): string {
const classes = []
if (apiKeyError.value) {
classes.push('border-destructive')
}
if (!apiKeyFocused.value && !form.value.api_key) {
classes.push('text-transparent caret-transparent selection:bg-transparent selection:text-transparent')
}
return classes.join(' ')
}
// API 密钥验证错误信息
const apiKeyError = computed(() => {
const apiKey = form.value.api_key.trim()
if (!apiKey) {
// 新增模式下必填
if (!props.editingKey) {
return '' // 空值由 required 属性处理
}
// 编辑模式下可以为空(表示不修改)
return ''
}
// 如果输入了值,检查长度
if (apiKey.length < 10) {
return 'API 密钥至少需要 10 个字符'
}
return ''
})
// 重置表单
function resetForm() {
formNonce.value = createFieldNonce()
apiKeyFocused.value = false
form.value = {
name: '',
api_key: '',
rate_multiplier: 1.0,
internal_priority: 50,
max_concurrent: undefined,
rate_limit: undefined,
daily_limit: undefined,
monthly_limit: undefined,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {}
}
}
// 加载密钥数据(编辑模式)
function loadKeyData() {
if (!props.editingKey) return
formNonce.value = createFieldNonce()
apiKeyFocused.value = false
form.value = {
name: props.editingKey.name,
api_key: '',
rate_multiplier: props.editingKey.rate_multiplier || 1.0,
internal_priority: props.editingKey.internal_priority ?? 50,
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,
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
is_active: props.editingKey.is_active,
capabilities: { ...(props.editingKey.capabilities || {}) }
}
}
// 使用 useFormDialog 统一处理对话框逻辑
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
isOpen: () => props.open,
entity: () => props.editingKey,
isLoading: saving,
onClose: () => emit('close'),
loadData: loadKeyData,
resetForm,
})
function createFieldNonce(): string {
return Math.random().toString(36).slice(2, 10)
}
async function handleSave() {
if (!props.endpoint) return
// 提交前验证
if (apiKeyError.value) {
showError(apiKeyError.value, '验证失败')
return
}
// 新增模式下API 密钥必填
if (!props.editingKey && !form.value.api_key.trim()) {
showError('请输入 API 密钥', '验证失败')
return
}
// 过滤出有效的能力配置(只包含值为 true 的)
const activeCapabilities: Record<string, boolean> = {}
for (const [key, value] of Object.entries(form.value.capabilities)) {
if (value) {
activeCapabilities[key] = true
}
}
const capabilitiesData = Object.keys(activeCapabilities).length > 0 ? activeCapabilities : null
saving.value = true
try {
if (props.editingKey) {
// 更新
const updateData: any = {
name: form.value.name,
rate_multiplier: form.value.rate_multiplier,
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,
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
is_active: form.value.is_active,
capabilities: capabilitiesData
}
if (form.value.api_key.trim()) {
updateData.api_key = form.value.api_key
}
await updateEndpointKey(props.editingKey.id, updateData)
success('密钥已更新', '成功')
} else {
// 新增
await addEndpointKey(props.endpoint.id, {
endpoint_id: props.endpoint.id,
api_key: form.value.api_key,
name: form.value.name,
rate_multiplier: form.value.rate_multiplier,
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,
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
capabilities: capabilitiesData || undefined
})
success('密钥已添加', '成功')
}
emit('saved')
emit('close')
} catch (err: any) {
const errorMessage = parseApiError(err, '保存密钥失败')
showError(errorMessage, '错误')
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,590 @@
<template>
<Dialog
:model-value="internalOpen"
title="优先级管理"
description="调整提供商和 API Key 的优先级顺序,保存后自动切换对应的调度策略"
:icon="ListOrdered"
size="3xl"
@update:model-value="handleDialogUpdate"
>
<div class="space-y-4">
<!-- Tab 切换 -->
<div class="flex gap-1 p-1 bg-muted/40 rounded-lg">
<button
type="button"
:class="[
'flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
activeMainTab === 'provider'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="activeMainTab = 'provider'"
>
<Layers class="w-4 h-4" />
<span>提供商优先</span>
</button>
<button
type="button"
:class="[
'flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
activeMainTab === 'key'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="activeMainTab = 'key'"
>
<Key class="w-4 h-4" />
<span>Key 优先</span>
</button>
</div>
<!-- 内容区域 -->
<div class="min-h-[420px]">
<!-- 提供商优先级 -->
<div v-show="activeMainTab === 'provider'" class="space-y-4">
<!-- 提示信息 -->
<div class="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded-md">
<Info class="w-3.5 h-3.5 shrink-0" />
<span>拖拽调整顺序位置越靠前优先级越高</span>
</div>
<!-- 空状态 -->
<div v-if="sortedProviders.length === 0" class="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Layers class="w-10 h-10 mb-3 opacity-20" />
<span class="text-sm">暂无提供商</span>
</div>
<!-- 提供商列表 -->
<div v-else class="space-y-2 max-h-[380px] overflow-y-auto pr-1">
<div
v-for="(provider, index) in sortedProviders"
:key="provider.id"
:class="[
'group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200',
draggedProvider === index
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
: dragOverProvider === index
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
]"
draggable="true"
@dragstart="handleProviderDragStart(index, $event)"
@dragend="handleProviderDragEnd"
@dragover.prevent="handleProviderDragOver(index)"
@dragleave="handleProviderDragLeave"
@drop="handleProviderDrop(index)"
>
<!-- 拖拽手柄 -->
<div class="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted text-muted-foreground/40 group-hover:text-muted-foreground transition-colors">
<GripVertical class="w-4 h-4" />
</div>
<!-- 序号 -->
<div class="w-6 h-6 rounded-md bg-muted/50 flex items-center justify-center text-xs font-medium text-muted-foreground shrink-0">
{{ index + 1 }}
</div>
<!-- 提供商信息 -->
<div class="flex-1 min-w-0 flex items-center gap-2">
<span class="font-medium text-sm truncate">{{ provider.display_name }}</span>
<Badge
v-if="!provider.is_active"
variant="secondary"
class="text-[10px] px-1.5 h-5 shrink-0"
>
停用
</Badge>
</div>
</div>
</div>
</div>
<!-- Key 优先级 -->
<div v-show="activeMainTab === 'key'" class="space-y-3">
<!-- 提示信息 -->
<div class="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded-md">
<Info class="w-3.5 h-3.5 shrink-0" />
<span>拖拽调整顺序点击序号可编辑相同数字为同级负载均衡</span>
</div>
<!-- 加载状态 -->
<div v-if="loadingKeys" class="flex items-center justify-center py-20">
<div class="flex flex-col items-center gap-2">
<div class="animate-spin rounded-full h-5 w-5 border-2 border-muted border-t-primary"></div>
<span class="text-xs text-muted-foreground">加载中...</span>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="availableFormats.length === 0" class="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Key class="w-10 h-10 mb-3 opacity-20" />
<span class="text-sm">暂无 API Key</span>
</div>
<!-- 左右布局格式列表 + Key 列表 -->
<div v-else class="flex gap-4">
<!-- 左侧API 格式列表 -->
<div class="w-32 shrink-0 space-y-1">
<button
v-for="format in availableFormats"
:key="format"
type="button"
:class="[
'w-full px-3 py-2 text-xs font-medium rounded-md text-left transition-all duration-200',
activeFormatTab === format
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
]"
@click="activeFormatTab = format"
>
{{ format }}
</button>
</div>
<!-- 右侧Key 列表 -->
<div class="flex-1 min-w-0">
<div v-for="format in availableFormats" :key="format" v-show="activeFormatTab === format">
<div v-if="keysByFormat[format]?.length > 0" class="space-y-2 max-h-[380px] overflow-y-auto pr-1">
<div
v-for="(key, index) in keysByFormat[format]"
:key="key.id"
:class="[
'group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200',
draggedKey[format] === index
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
: dragOverKey[format] === index
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
]"
draggable="true"
@dragstart="handleKeyDragStart(format, index, $event)"
@dragend="handleKeyDragEnd(format)"
@dragover.prevent="handleKeyDragOver(format, index)"
@dragleave="handleKeyDragLeave(format)"
@drop="handleKeyDrop(format, index)"
>
<!-- 拖拽手柄 -->
<div class="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted text-muted-foreground/40 group-hover:text-muted-foreground transition-colors shrink-0">
<GripVertical class="w-4 h-4" />
</div>
<!-- 可编辑序号 -->
<div class="shrink-0">
<input
v-if="editingKeyPriority[format] === key.id"
type="number"
min="1"
:value="key.priority"
class="w-8 h-6 rounded-md bg-background border border-primary text-xs font-medium text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@blur="finishEditKeyPriority(format, key, $event)"
@keydown.enter="($event.target as HTMLInputElement).blur()"
@keydown.escape="cancelEditKeyPriority(format)"
autofocus
/>
<div
v-else
class="w-6 h-6 rounded-md bg-muted/50 flex items-center justify-center text-xs font-medium text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary transition-colors"
:title="'点击编辑优先级,相同数字为同级(负载均衡)'"
@click.stop="startEditKeyPriority(format, key)"
>
{{ key.priority }}
</div>
</div>
<!-- Key 信息 -->
<div class="flex-1 min-w-0 flex items-center gap-3">
<!-- 左侧名称和来源 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{{ key.name }}</span>
<Badge
v-if="key.circuit_breaker_open"
variant="destructive"
class="text-[10px] h-5 px-1.5 shrink-0"
>
熔断
</Badge>
<Badge
v-else-if="!key.is_active"
variant="secondary"
class="text-[10px] h-5 px-1.5 shrink-0"
>
停用
</Badge>
<!-- 能力标签紧跟名称 -->
<template v-if="key.capabilities?.length">
<span v-for="cap in key.capabilities.slice(0, 2)" :key="cap" class="px-1 py-0.5 bg-muted text-muted-foreground rounded text-[10px]">{{ cap }}</span>
<span v-if="key.capabilities.length > 2" class="text-[10px] text-muted-foreground">+{{ key.capabilities.length - 2 }}</span>
</template>
</div>
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mt-1">
<span class="text-[10px] font-medium shrink-0">{{ key.provider_name }}</span>
<span class="font-mono text-[10px] opacity-60 truncate">{{ key.api_key_masked }}</span>
</div>
</div>
<!-- 右侧健康度 + 速率 -->
<div class="shrink-0 flex items-center gap-3">
<!-- 健康度 -->
<div v-if="key.success_rate !== null" class="text-xs text-right">
<div :class="[
'font-medium tabular-nums',
key.success_rate >= 0.95 ? 'text-green-600' :
key.success_rate >= 0.8 ? 'text-yellow-600' : 'text-red-500'
]">
{{ (key.success_rate * 100).toFixed(0) }}%
</div>
<div class="text-[10px] text-muted-foreground opacity-70">{{ key.request_count }} reqs</div>
</div>
<div v-else class="text-xs text-muted-foreground/50 text-right">
<div>--</div>
<div class="text-[10px]">无数据</div>
</div>
<!-- 速率倍数 -->
<div class="text-sm font-medium tabular-nums text-primary min-w-[40px] text-right">{{ key.rate_multiplier }}x</div>
</div>
</div>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Key class="w-10 h-10 mb-3 opacity-20" />
<span class="text-sm">暂无 {{ format }} 格式的 Key</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between w-full">
<div class="text-xs text-muted-foreground">
当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
</div>
<div class="flex gap-2">
<Button size="sm" @click="save" :disabled="saving" class="min-w-[72px]">
<Loader2 v-if="saving" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
{{ saving ? '保存中' : '保存' }}
</Button>
<Button variant="outline" size="sm" @click="close" class="min-w-[72px]">
取消
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { GripVertical, Layers, Key, Info, Loader2, ListOrdered } from 'lucide-vue-next'
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 type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { adminApi } from '@/api/admin'
interface KeyWithMeta {
id: string
name: string
api_key_masked: string
internal_priority: number
global_priority: number | null
priority: number // 用于编辑的优先级
rate_multiplier: number
is_active: boolean
circuit_breaker_open: boolean
provider_name: string
endpoint_base_url: string
api_format: string
capabilities: string[]
success_rate: number | null
avg_response_time_ms: number | null
request_count: number
}
const props = defineProps<{
modelValue: boolean
providers: ProviderWithEndpointsSummary[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
}>()
const { success, error: showError } = useToast()
// 内部状态
const internalOpen = computed(() => props.modelValue)
function handleDialogUpdate(value: boolean) {
emit('update:modelValue', value)
}
// 主 Tab 状态
const activeMainTab = ref<'provider' | 'key'>('provider')
const activeFormatTab = ref<string>('CLAUDE')
// 提供商排序状态
const sortedProviders = ref<ProviderWithEndpointsSummary[]>([])
const draggedProvider = ref<number | null>(null)
const dragOverProvider = ref<number | null>(null)
// Key 排序状态
const keysByFormat = ref<Record<string, KeyWithMeta[]>>({})
const draggedKey = ref<Record<string, number | null>>({})
const dragOverKey = ref<Record<string, number | null>>({})
const loadingKeys = ref(false)
const saving = ref(false)
// Key 优先级编辑状态
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
// 可用的 API 格式
const availableFormats = computed(() => {
return Object.keys(keysByFormat.value).sort()
})
// 监听 props.providers 变化
watch(() => props.providers, (newProviders) => {
if (newProviders) {
sortedProviders.value = [...newProviders].sort((a, b) => a.provider_priority - b.provider_priority)
}
}, { immediate: true })
// 监听对话框打开
watch(internalOpen, async (open) => {
if (open) {
await loadCurrentPriorityMode()
await loadKeysByFormat()
}
})
// 加载当前的优先级模式配置
async function loadCurrentPriorityMode() {
try {
const response = await adminApi.getSystemConfig('provider_priority_mode')
const currentMode = response.value || 'provider'
activeMainTab.value = currentMode === 'global_key' ? 'key' : 'provider'
} catch {
activeMainTab.value = 'provider'
}
}
// 加载按格式分组的 Keys
async function loadKeysByFormat() {
try {
loadingKeys.value = true
const { default: client } = await import('@/api/client')
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
// 为每个 key 添加 priority 字段,基于 global_priority 计算显示优先级
const data: Record<string, KeyWithMeta[]> = {}
for (const [format, keys] of Object.entries(response.data as Record<string, any[]>)) {
data[format] = keys.map((key, index) => ({
...key,
priority: key.global_priority ?? index + 1
}))
}
keysByFormat.value = data
const formats = Object.keys(data)
if (formats.length > 0 && !formats.includes(activeFormatTab.value)) {
activeFormatTab.value = formats[0]
}
} catch (err: any) {
showError(err.response?.data?.detail || '加载 Key 列表失败', '错误')
} finally {
loadingKeys.value = false
}
}
// Key 优先级编辑
function startEditKeyPriority(format: string, key: KeyWithMeta) {
editingKeyPriority.value[format] = key.id
}
function cancelEditKeyPriority(format: string) {
editingKeyPriority.value[format] = null
}
function finishEditKeyPriority(format: string, key: KeyWithMeta, event: FocusEvent) {
const input = event.target as HTMLInputElement
const newPriority = parseInt(input.value, 10)
if (!isNaN(newPriority) && newPriority >= 1) {
key.priority = newPriority
// 按 priority 重新排序
keysByFormat.value[format] = [...keysByFormat.value[format]].sort((a, b) => a.priority - b.priority)
}
editingKeyPriority.value[format] = null
}
// Provider 拖拽处理
function handleProviderDragStart(index: number, event: DragEvent) {
draggedProvider.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/html', '')
}
}
function handleProviderDragEnd() {
draggedProvider.value = null
dragOverProvider.value = null
}
function handleProviderDragOver(index: number) {
dragOverProvider.value = index
}
function handleProviderDragLeave() {
dragOverProvider.value = null
}
function handleProviderDrop(dropIndex: number) {
if (draggedProvider.value === null || draggedProvider.value === dropIndex) {
return
}
const providers = [...sortedProviders.value]
const draggedItem = providers[draggedProvider.value]
providers.splice(draggedProvider.value, 1)
providers.splice(dropIndex, 0, draggedItem)
sortedProviders.value = providers.map((provider, index) => ({
...provider,
provider_priority: index + 1
}))
draggedProvider.value = null
}
// Key 拖拽处理
function handleKeyDragStart(format: string, index: number, event: DragEvent) {
draggedKey.value[format] = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/html', '')
}
}
function handleKeyDragEnd(format: string) {
draggedKey.value[format] = null
dragOverKey.value[format] = null
}
function handleKeyDragOver(format: string, index: number) {
dragOverKey.value[format] = index
}
function handleKeyDragLeave(format: string) {
dragOverKey.value[format] = null
}
function handleKeyDrop(format: string, dropIndex: number) {
const dragIndex = draggedKey.value[format]
if (dragIndex === null || dragIndex === dropIndex) {
draggedKey.value[format] = null
return
}
const keys = [...keysByFormat.value[format]]
const draggedItem = keys[dragIndex]
// 记录每个 key 的原始优先级(在修改前)
const originalPriorityMap = new Map<string, number>()
for (const key of keys) {
originalPriorityMap.set(key.id, key.priority)
}
// 重排数组
keys.splice(dragIndex, 1)
keys.splice(dropIndex, 0, draggedItem)
// 按新顺序为每个组分配新的优先级
// 同组的 Key 保持相同的优先级
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
let currentPriority = 1
for (const key of keys) {
if (key.id === draggedItem.id) {
// 被拖动的 Key 是独立的新组,获得当前优先级
key.priority = currentPriority
currentPriority++
} else {
// 使用记录的原始优先级,而不是可能已被修改的值
const originalPriority = originalPriorityMap.get(key.id)!
if (groupNewPriority.has(originalPriority)) {
// 这个组已经分配过优先级,使用相同的值
key.priority = groupNewPriority.get(originalPriority)!
} else {
// 这个组第一次出现,分配新优先级
groupNewPriority.set(originalPriority, currentPriority)
key.priority = currentPriority
currentPriority++
}
}
}
keysByFormat.value[format] = keys
draggedKey.value[format] = null
}
// 保存
async function save() {
try {
saving.value = true
const newMode = activeMainTab.value === 'key' ? 'global_key' : 'provider'
await adminApi.updateSystemConfig(
'provider_priority_mode',
newMode,
'Provider/Key 优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)'
)
const providerUpdates = sortedProviders.value.map((provider, index) =>
updateProvider(provider.id, { provider_priority: index + 1 })
)
const keyUpdates: Promise<any>[] = []
for (const format of Object.keys(keysByFormat.value)) {
const keys = keysByFormat.value[format]
keys.forEach((key) => {
// 使用用户设置的 priority 值,相同 priority 会做负载均衡
keyUpdates.push(updateEndpointKey(key.id, { global_priority: key.priority }))
})
}
await Promise.all([...providerUpdates, ...keyUpdates])
await loadKeysByFormat()
success('优先级已保存')
emit('saved')
// 提供商优先模式保存后关闭Key 优先模式保存后保持打开方便继续调整
if (activeMainTab.value === 'provider') {
close()
}
} catch (err: any) {
showError(err.response?.data?.detail || '保存失败', '错误')
} finally {
saving.value = false
}
}
function close() {
emit('update:modelValue', false)
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
<template>
<Dialog
:model-value="internalOpen"
:title="isEditMode ? '编辑提供商' : '添加提供商'"
:description="isEditMode ? '更新提供商配置。API 端点和密钥需在详情页面单独管理。' : '创建新的提供商配置。创建后可以为其添加 API 端点和密钥。'"
:icon="isEditMode ? SquarePen : Server"
size="2xl"
@update:model-value="handleDialogUpdate"
>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- 基本信息 -->
<div class="space-y-4">
<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>
<Input
id="display_name"
v-model="form.display_name"
placeholder="例如: OpenAI 主账号"
required
/>
</div>
<div class="space-y-2">
<Label for="website">主站链接</Label>
<Input
id="website"
v-model="form.website"
placeholder="https://..."
type="url"
/>
</div>
</div>
<div class="space-y-2">
<Label for="description">描述</Label>
<Textarea
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="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>计费类型</Label>
<Select v-model="form.billing_type" v-model:open="billingTypeSelectOpen">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<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>
</div>
<!-- 月卡配置 -->
<div 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">
<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">
<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">
<Label class="text-xs">
周期开始时间
<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">
<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>
</form>
<template #footer>
<Button
type="button"
variant="outline"
@click="handleCancel"
:disabled="loading"
>
取消
</Button>
<Button
@click="handleSubmit"
:disabled="loading || !form.display_name || (!isEditMode && !form.name)"
>
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存' : '创建') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Textarea from '@/components/ui/textarea.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { Server, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { createProvider, updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput } from '@/utils/form'
const props = defineProps<{
modelValue: boolean
provider?: ProviderWithEndpointsSummary | null // 编辑模式时传入
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'provider-created': []
'provider-updated': []
}>()
const { success, error: showError } = useToast()
const loading = ref(false)
const billingTypeSelectOpen = ref(false)
// 内部状态
const internalOpen = computed(() => props.modelValue)
// 表单数据
const form = ref({
name: '',
display_name: '',
description: '',
website: '',
// 计费配置
billing_type: 'pay_as_you_go' as 'monthly_quota' | 'pay_as_you_go' | 'free_tier',
monthly_quota_usd: undefined as number | undefined,
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,
})
// 重置表单
function resetForm() {
form.value = {
name: '',
display_name: '',
description: '',
website: '',
billing_type: 'pay_as_you_go',
monthly_quota_usd: undefined,
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,
}
}
// 加载提供商数据(编辑模式)
function loadProviderData() {
if (!props.provider) return
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',
monthly_quota_usd: props.provider.monthly_quota_usd || undefined,
quota_reset_day: props.provider.quota_reset_day || 30,
quota_last_reset_at: props.provider.quota_last_reset_at ?
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,
}
}
// 使用 useFormDialog 统一处理对话框逻辑
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
isOpen: () => props.modelValue,
entity: () => props.provider,
isLoading: loading,
onClose: () => emit('update:modelValue', false),
loadData: loadProviderData,
resetForm,
})
// 提交表单
const handleSubmit = async () => {
// 月卡类型必须设置周期开始时间
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
showError('月卡类型必须设置周期开始时间', '验证失败')
return
}
loading.value = true
try {
const payload = {
...form.value,
rpm_limit:
form.value.rpm_limit === undefined || form.value.rpm_limit === ''
? null
: Number(form.value.rpm_limit),
// 空字符串时不发送
quota_last_reset_at: form.value.quota_last_reset_at || undefined,
quota_expires_at: form.value.quota_expires_at || undefined,
}
if (isEditMode.value && props.provider) {
// 更新提供商
await updateProvider(props.provider.id, payload)
success('提供商更新成功')
emit('provider-updated')
} else {
// 创建提供商
await createProvider(payload)
success('提供商已创建,请继续添加端点和密钥,或在优先级管理中调整顺序', '创建成功')
emit('provider-created')
}
emit('update:modelValue', false)
} catch (error: any) {
const action = isEditMode.value ? '更新' : '创建'
showError(parseApiError(error, `${action}提供商失败`), `${action}失败`)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,368 @@
<template>
<Dialog
:model-value="open"
@update:model-value="handleClose"
:title="isEditing ? '编辑模型配置' : '添加模型'"
:description="isEditing ? '修改模型价格和能力配置' : '为此 Provider 添加模型实现'"
:icon="isEditing ? SquarePen : Layers"
size="xl"
>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- 添加模式选择全局模型 -->
<div v-if="!isEditing" class="space-y-2">
<Label for="global-model">选择模型 *</Label>
<Select
v-model:open="globalModelSelectOpen"
:model-value="form.global_model_id"
:disabled="loadingGlobalModels"
@update:model-value="form.global_model_id = $event"
>
<SelectTrigger class="w-full">
<SelectValue :placeholder="loadingGlobalModels ? '加载中...' : '请选择模型'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="model in availableGlobalModels"
:key="model.id"
:value="model.id"
>
{{ model.display_name }} ({{ model.name }})
</SelectItem>
</SelectContent>
</Select>
<p v-if="availableGlobalModels.length === 0 && !loadingGlobalModels" class="text-xs text-muted-foreground">
所有全局模型已添加到此 Provider
</p>
</div>
<!-- 编辑模式显示模型信息 -->
<div v-else class="rounded-lg border bg-muted/30 p-4">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-lg">{{ editingModel?.global_model_display_name || editingModel?.provider_model_name }}</p>
<p class="text-sm text-muted-foreground font-mono">{{ editingModel?.provider_model_name }}</p>
</div>
</div>
</div>
<!-- 价格配置 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm border-b pb-2">价格配置</h4>
<TieredPricingEditor v-model="tieredPricing" :show-cache1h="showCache1h" />
<!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费 ($/)</Label>
<Input
:model-value="form.price_per_request ?? ''"
type="number"
step="0.001"
min="0"
class="w-32"
placeholder="留空使用默认值"
@update:model-value="(v) => form.price_per_request = parseNumberInput(v, { allowFloat: true })"
/>
<span class="text-xs text-muted-foreground">每次请求固定费用,留空使用全局模型默认值</span>
</div>
</div>
<!-- 能力配置 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm border-b pb-2">能力配置</h4>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
type="checkbox"
v-model="form.supports_streaming"
:indeterminate="form.supports_streaming === undefined"
class="rounded"
/>
<Zap class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">流式输出</span>
</label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
type="checkbox"
v-model="form.supports_image_generation"
:indeterminate="form.supports_image_generation === undefined"
class="rounded"
/>
<Image class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">图像生成</span>
</label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
type="checkbox"
v-model="form.supports_vision"
:indeterminate="form.supports_vision === undefined"
class="rounded"
/>
<Eye class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">视觉理解</span>
</label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
type="checkbox"
v-model="form.supports_function_calling"
:indeterminate="form.supports_function_calling === undefined"
class="rounded"
/>
<Wrench class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">工具调用</span>
</label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input
type="checkbox"
v-model="form.supports_extended_thinking"
:indeterminate="form.supports_extended_thinking === undefined"
class="rounded"
/>
<Brain class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">深度思考</span>
</label>
</div>
</div>
</form>
<template #footer>
<Button variant="outline" @click="handleClose(false)">
取消
</Button>
<Button @click="handleSubmit" :disabled="submitting || (!isEditing && !form.global_model_id)">
<Loader2 v-if="submitting" class="w-4 h-4 mr-2 animate-spin" />
{{ isEditing ? '保存' : '添加' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Eye, Wrench, Brain, Zap, Loader2, Image, Layers, SquarePen } from 'lucide-vue-next'
import { Dialog, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import { useToast } from '@/composables/useToast'
import { parseNumberInput } from '@/utils/form'
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
import type { Model, TieredPricingConfig } from '@/api/endpoints'
interface Props {
open: boolean
providerId: string
providerName?: string
editingModel?: Model | null
}
const props = withDefaults(defineProps<Props>(), {
providerName: '',
editingModel: null
})
const emit = defineEmits<{
'update:open': [value: boolean]
'saved': []
}>()
const { error: showError, success: showSuccess } = useToast()
const isEditing = computed(() => !!props.editingModel)
// 计算是否显示 1h 缓存输入框
const showCache1h = computed(() => {
if (isEditing.value) {
// 编辑模式:检查当前配置是否有 1h 缓存配置(从 tiered_pricing 或 effective_tiered_pricing 中检测)
const pricing = props.editingModel?.tiered_pricing || props.editingModel?.effective_tiered_pricing
return pricing?.tiers?.some(t => t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60)) ?? false
} else {
// 添加模式:从选中的全局模型中读取 supported_capabilities
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
return selectedModel?.supported_capabilities?.includes('cache_1h') ?? false
}
})
// 表单状态
const submitting = ref(false)
const loadingGlobalModels = ref(false)
const availableGlobalModels = ref<GlobalModelResponse[]>([])
const globalModelSelectOpen = ref(false)
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
// 跟踪用户是否修改了阶梯配置(用于判断是否提交)
const tieredPricingModified = ref(false)
// 保存原始配置用于比较
const originalTieredPricing = ref<string>('')
const form = ref({
global_model_id: '',
price_per_request: undefined as number | undefined,
// 能力配置
supports_vision: undefined as boolean | undefined,
supports_function_calling: undefined as boolean | undefined,
supports_streaming: undefined as boolean | undefined,
supports_extended_thinking: undefined as boolean | undefined,
supports_image_generation: undefined as boolean | undefined,
is_active: true
})
// 监听 open 变化
watch(() => props.open, async (newOpen) => {
if (newOpen) {
resetForm()
if (props.editingModel) {
// 编辑模式:填充表单
form.value = {
global_model_id: props.editingModel.global_model_id || '',
price_per_request: props.editingModel.price_per_request ?? undefined,
supports_vision: props.editingModel.supports_vision ?? undefined,
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
supports_streaming: props.editingModel.supports_streaming ?? undefined,
supports_extended_thinking: props.editingModel.supports_extended_thinking ?? undefined,
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
is_active: props.editingModel.is_active
}
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
if (pricing) {
tieredPricing.value = JSON.parse(JSON.stringify(pricing))
}
} else {
// 添加模式:加载可用全局模型
await loadAvailableGlobalModels()
}
}
})
// 添加模式:选择全局模型时显示其阶梯计费配置(仅供预览)
// 注意:为保持继承关系,添加时只有用户修改了配置才提交 tiered_pricing
watch(() => form.value.global_model_id, (newId) => {
if (!isEditing.value && newId) {
const selectedModel = availableGlobalModels.value.find(m => m.id === newId)
if (selectedModel?.default_tiered_pricing) {
// 深拷贝阶梯计费配置用于预览
const pricingCopy = JSON.parse(JSON.stringify(selectedModel.default_tiered_pricing))
tieredPricing.value = pricingCopy
// 保存原始配置用于比较
originalTieredPricing.value = JSON.stringify(pricingCopy)
} else {
tieredPricing.value = null
originalTieredPricing.value = ''
}
tieredPricingModified.value = false
// 同时继承按次计费(仅供预览)
form.value.price_per_request = selectedModel?.default_price_per_request ?? undefined
}
})
// 监听阶梯配置变化,标记为已修改
watch(tieredPricing, (newValue) => {
if (!isEditing.value && originalTieredPricing.value) {
const newJson = JSON.stringify(newValue)
tieredPricingModified.value = newJson !== originalTieredPricing.value
}
}, { deep: true })
// 重置表单
function resetForm() {
form.value = {
global_model_id: '',
price_per_request: undefined,
supports_vision: undefined,
supports_function_calling: undefined,
supports_streaming: undefined,
supports_extended_thinking: undefined,
supports_image_generation: undefined,
is_active: true
}
tieredPricing.value = null
tieredPricingModified.value = false
originalTieredPricing.value = ''
availableGlobalModels.value = []
}
// 加载可用的全局模型(排除已添加的)
async function loadAvailableGlobalModels() {
loadingGlobalModels.value = true
try {
const [globalModelsResponse, existingModels] = await Promise.all([
listGlobalModels({ limit: 1000, is_active: true }),
getProviderModels(props.providerId)
])
const allGlobalModels = globalModelsResponse.models || []
// 获取当前 provider 已添加的模型的 global_model_id 列表
const existingGlobalModelIds = new Set(
existingModels.map((m: Model) => m.global_model_id).filter(Boolean)
)
// 过滤掉已添加的模型
availableGlobalModels.value = allGlobalModels.filter(
(gm: GlobalModelResponse) => !existingGlobalModelIds.has(gm.id)
)
} catch (err: any) {
showError(err.response?.data?.detail || '加载模型列表失败', '错误')
} finally {
loadingGlobalModels.value = false
}
}
// 关闭对话框
function handleClose(value: boolean) {
if (!submitting.value) {
emit('update:open', value)
}
}
// 提交表单
async function handleSubmit() {
if (submitting.value) return
submitting.value = true
try {
if (isEditing.value && props.editingModel) {
// 编辑模式
// 注意:使用 null 而不是 undefined 来显式清空字段undefined 会被 JSON 序列化忽略)
await updateModel(props.providerId, props.editingModel.id, {
tiered_pricing: tieredPricing.value,
price_per_request: form.value.price_per_request ?? null,
supports_vision: form.value.supports_vision,
supports_function_calling: form.value.supports_function_calling,
supports_streaming: form.value.supports_streaming,
supports_extended_thinking: form.value.supports_extended_thinking,
supports_image_generation: form.value.supports_image_generation,
is_active: form.value.is_active
})
showSuccess('模型配置已更新')
} else {
// 添加模式:只有用户修改了配置才提交 tiered_pricing否则保持继承关系
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
await createModel(props.providerId, {
global_model_id: form.value.global_model_id,
provider_model_name: selectedModel?.name || '',
// 只有修改了才提交,否则传 undefined 让后端继承 GlobalModel 配置
tiered_pricing: tieredPricingModified.value ? tieredPricing.value : undefined,
price_per_request: form.value.price_per_request,
supports_vision: form.value.supports_vision,
supports_function_calling: form.value.supports_function_calling,
supports_streaming: form.value.supports_streaming,
supports_extended_thinking: form.value.supports_extended_thinking,
supports_image_generation: form.value.supports_image_generation,
is_active: form.value.is_active
})
showSuccess('模型已添加')
}
emit('update:open', false)
emit('saved')
} catch (err: any) {
showError(err.response?.data?.detail || (isEditing.value ? '更新失败' : '添加失败'), '错误')
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,12 @@
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 PriorityManagementDialog } from './PriorityManagementDialog.vue'
export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vue'
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
export { default as MappingsTab } from './provider-tabs/MappingsTab.vue'
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'

View File

@@ -0,0 +1,280 @@
<template>
<Card class="overflow-hidden">
<!-- 标题头部 -->
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold leading-none">
别名与映射管理
</h3>
</div>
<Button
v-if="!hideAddButton"
@click="openCreateDialog"
variant="outline"
size="sm"
class="h-8"
>
<Plus class="w-3.5 h-3.5 mr-1.5" />
创建别名/映射
</Button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- 别名列表 -->
<div v-else-if="mappings.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th class="text-left px-4 py-3 font-semibold">名称</th>
<th class="text-left px-4 py-3 font-semibold w-24">类型</th>
<th class="text-left px-4 py-3 font-semibold">指向模型</th>
<th v-if="!hideAddButton" class="px-4 py-3 font-semibold w-28 text-center">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="mapping in mappings"
:key="mapping.id"
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<!-- 状态指示灯 -->
<span
class="w-2 h-2 rounded-full shrink-0"
:class="mapping.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="mapping.is_active ? '活跃' : '停用'"
></span>
<span class="font-mono">{{ mapping.alias }}</span>
</div>
</td>
<td class="px-4 py-3">
<Badge variant="secondary" class="text-xs">
{{ mapping.mapping_type === 'mapping' ? '映射' : '别名' }}
</Badge>
</td>
<td class="px-4 py-3">
{{ mapping.global_model_display_name || mapping.global_model_name }}
</td>
<td v-if="!hideAddButton" class="px-4 py-3">
<div class="flex justify-center gap-1.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑"
@click="openEditDialog(mapping)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="togglingId === mapping.id"
@click="toggleActive(mapping)"
:title="mapping.is_active ? '点击停用' : '点击启用'"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
title="删除"
@click="confirmDelete(mapping)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 空状态 -->
<div v-else class="p-8 text-center text-muted-foreground">
<ArrowLeftRight class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">暂无特定别名/映射</p>
<p class="text-xs mt-1">点击上方按钮添加</p>
</div>
</Card>
<!-- 使用共享的 AliasDialog 组件 -->
<AliasDialog
:open="dialogOpen"
:editing-alias="editingAlias"
:global-models="availableModels"
:fixed-provider="fixedProviderOption"
:show-provider-select="true"
@update:open="handleDialogVisibility"
@submit="handleAliasSubmit"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ArrowLeftRight, Plus, Edit, Trash2, Power } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import AliasDialog from '@/features/models/components/AliasDialog.vue'
import { useToast } from '@/composables/useToast'
import {
getAliases,
createAlias,
updateAlias,
deleteAlias,
type ModelAlias,
type CreateModelAliasRequest,
type UpdateModelAliasRequest,
} from '@/api/endpoints/aliases'
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
const props = withDefaults(defineProps<{
provider: any
hideAddButton?: boolean
}>(), {
hideAddButton: false
})
const emit = defineEmits<{
refresh: []
}>()
const { success, error: showError } = useToast()
// 状态
const loading = ref(false)
const submitting = ref(false)
const togglingId = ref<string | null>(null)
const mappings = ref<ModelAlias[]>([])
const availableModels = ref<GlobalModelResponse[]>([])
const dialogOpen = ref(false)
const editingAlias = ref<ModelAlias | null>(null)
// 固定的 Provider 选项(传递给 AliasDialog
const fixedProviderOption = computed(() => ({
id: props.provider.id,
name: props.provider.name,
display_name: props.provider.display_name
}))
// 加载映射 (实际返回的是该 Provider 的别名列表)
async function loadMappings() {
try {
loading.value = true
mappings.value = await getAliases({ provider_id: props.provider.id })
} catch (err: any) {
showError(err.response?.data?.detail || '加载失败', '错误')
} finally {
loading.value = false
}
}
// 加载可用的 GlobalModel 列表
async function loadAvailableModels() {
try {
const response = await listGlobalModels({ limit: 1000, is_active: true })
availableModels.value = response.models || []
} catch (err: any) {
showError(err.response?.data?.detail || '加载模型列表失败', '错误')
}
}
// 打开创建对话框
function openCreateDialog() {
editingAlias.value = null
dialogOpen.value = true
}
// 打开编辑对话框
function openEditDialog(alias: ModelAlias) {
editingAlias.value = alias
dialogOpen.value = true
}
// 处理对话框可见性变化
function handleDialogVisibility(value: boolean) {
dialogOpen.value = value
if (!value) {
editingAlias.value = null
}
}
// 处理别名提交(来自 AliasDialog 组件)
async function handleAliasSubmit(data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean) {
submitting.value = true
try {
if (isEdit && editingAlias.value) {
// 更新
await updateAlias(editingAlias.value.id, data as UpdateModelAliasRequest)
success(data.mapping_type === 'mapping' ? '映射已更新' : '别名已更新')
} else {
// 创建 - 确保 provider_id 设置为当前 Provider
const createData = data as CreateModelAliasRequest
createData.provider_id = props.provider.id
await createAlias(createData)
success(data.mapping_type === 'mapping' ? '映射已创建' : '别名已创建')
}
dialogOpen.value = false
editingAlias.value = null
await loadMappings()
emit('refresh')
} catch (err: any) {
const detail = err.response?.data?.detail || err.message
let errorMessage = detail
if (detail === '映射已存在') {
errorMessage = '该名称已存在,请使用其他名称'
}
showError(errorMessage, isEdit ? '更新失败' : '创建失败')
} finally {
submitting.value = false
}
}
// 切换启用状态
async function toggleActive(alias: ModelAlias) {
if (togglingId.value) return
togglingId.value = alias.id
try {
const newStatus = !alias.is_active
await updateAlias(alias.id, { is_active: newStatus })
alias.is_active = newStatus
} catch (err: any) {
showError(err.response?.data?.detail || '操作失败', '错误')
} finally {
togglingId.value = null
}
}
// 确认删除
async function confirmDelete(alias: ModelAlias) {
const typeName = alias.mapping_type === 'mapping' ? '映射' : '别名'
if (!confirm(`确定要删除${typeName} "${alias.alias}" 吗?`)) {
return
}
try {
await deleteAlias(alias.id)
success(`${typeName}已删除`)
await loadMappings()
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || err.message, '删除失败')
}
}
onMounted(() => {
loadMappings()
loadAvailableModels()
})
</script>

View File

@@ -0,0 +1,331 @@
<template>
<Card class="overflow-hidden">
<!-- 标题头部 -->
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold flex items-center gap-2">
模型列表
</h3>
<Button @click="openBatchAssignDialog" variant="outline" size="sm" class="h-8">
<Layers class="w-3.5 h-3.5 mr-1.5" />
关联模型
</Button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- 模型列表 -->
<div v-else-if="models.length > 0" class="overflow-hidden">
<table class="w-full text-sm table-fixed">
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th class="text-left px-4 py-3 font-semibold w-[40%]">模型</th>
<th class="text-left px-4 py-3 font-semibold w-[15%]">能力</th>
<th class="text-left px-4 py-3 font-semibold w-[25%]">价格 ($/M)</th>
<th class="text-center px-4 py-3 font-semibold w-[20%]">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="model in sortedModels"
:key="model.id"
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
>
<td class="align-top px-4 py-3">
<div class="flex items-center gap-2.5">
<!-- 状态指示灯 -->
<div
class="w-2 h-2 rounded-full shrink-0"
:class="getStatusIndicatorClass(model)"
:title="getStatusTitle(model)"
></div>
<!-- 模型信息 -->
<div class="text-left flex-1 min-w-0">
<span class="font-semibold text-sm">
{{ model.global_model_display_name || model.provider_model_name }}
</span>
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<span class="font-mono truncate">{{ model.provider_model_name }}</span>
<button
@click.stop="copyModelId(model.provider_model_name)"
class="p-0.5 hover:bg-muted rounded transition-colors shrink-0"
title="复制模型 ID"
>
<Copy class="w-3 h-3" />
</button>
</div>
</div>
</div>
</td>
<td class="align-top px-4 py-3">
<div v-if="hasAnyCapability(model)" class="grid grid-cols-3 gap-1 w-fit">
<Zap v-if="model.effective_supports_streaming ?? model.supports_streaming" class="w-4 h-4 text-muted-foreground" title="流式输出" />
<Image v-if="model.effective_supports_image_generation ?? model.supports_image_generation" class="w-4 h-4 text-muted-foreground" title="图像生成" />
<Eye v-if="model.effective_supports_vision ?? model.supports_vision" class="w-4 h-4 text-muted-foreground" title="视觉理解" />
<Wrench v-if="model.effective_supports_function_calling ?? model.supports_function_calling" class="w-4 h-4 text-muted-foreground" title="工具调用" />
<Brain v-if="model.effective_supports_extended_thinking ?? model.supports_extended_thinking" class="w-4 h-4 text-muted-foreground" title="深度思考" />
</div>
<span v-else class="text-xs text-muted-foreground"></span>
</td>
<td class="align-top px-4 py-3 text-xs whitespace-nowrap">
<div class="grid gap-1" style="grid-template-columns: auto 1fr;">
<!-- Token 计费 -->
<template v-if="hasTokenPricing(model)">
<span class="text-muted-foreground text-right">输入/输出:</span>
<span class="font-mono font-semibold">
${{ formatPrice(model.effective_input_price) }}/${{ formatPrice(model.effective_output_price) }}
</span>
</template>
<template v-if="getEffectiveCachePrice(model, 'creation') > 0 || getEffectiveCachePrice(model, 'read') > 0">
<span class="text-muted-foreground text-right">缓存:</span>
<span class="font-mono font-semibold">
${{ formatPrice(getEffectiveCachePrice(model, 'creation')) }}/${{ formatPrice(getEffectiveCachePrice(model, 'read')) }}
</span>
</template>
<!-- 1h 缓存价格 -->
<template v-if="get1hCachePrice(model) > 0">
<span class="text-muted-foreground text-right">1h 缓存:</span>
<span class="font-mono font-semibold">
${{ formatPrice(get1hCachePrice(model)) }}
</span>
</template>
<!-- 按次计费 -->
<template v-if="hasRequestPricing(model)">
<span class="text-muted-foreground text-right">按次:</span>
<span class="font-mono font-semibold">
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/
</span>
</template>
<!-- 无计费配置 -->
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
<span class="text-muted-foreground"></span>
</template>
</div>
</td>
<td class="align-top px-4 py-3">
<div class="flex justify-center gap-1.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="editModel(model)"
title="编辑"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="togglingModelId === model.id"
@click="toggleModelActive(model)"
:title="model.is_active ? '点击停用' : '点击启用'"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="deleteModel(model)"
title="删除"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 空状态 -->
<div v-else class="p-8 text-center text-muted-foreground">
<Box class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">暂无模型</p>
<p class="text-xs mt-1">请前往"模型目录"页面添加模型</p>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import { useToast } from '@/composables/useToast'
import { getProviderModels, type Model } from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
const props = defineProps<{
provider: any
}>()
const emit = defineEmits<{
'edit-model': [model: Model]
'delete-model': [model: Model]
'batch-assign': []
}>()
const { error: showError, success: showSuccess } = useToast()
// 状态
const loading = ref(false)
const models = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
// 按名称排序的模型列表
const sortedModels = computed(() => {
return [...models.value].sort((a, b) => {
const nameA = (a.global_model_display_name || a.provider_model_name || '').toLowerCase()
const nameB = (b.global_model_display_name || b.provider_model_name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
// 复制模型 ID 到剪贴板
async function copyModelId(modelId: string) {
try {
await navigator.clipboard.writeText(modelId)
showSuccess('已复制到剪贴板')
} catch {
showError('复制失败', '错误')
}
}
// 加载模型
async function loadModels() {
try {
loading.value = true
models.value = await getProviderModels(props.provider.id)
} catch (err: any) {
showError(err.response?.data?.detail || '加载失败', '错误')
} finally {
loading.value = false
}
}
// 格式化价格显示
function formatPrice(price: number | null | undefined): string {
if (price === null || price === undefined) return '-'
// 如果是整数或小数点后只有1-2位直接显示
if (price >= 0.01 || price === 0) {
return price.toFixed(2)
}
// 对于非常小的数字,使用科学计数法
if (price < 0.0001) {
return price.toExponential(2)
}
// 其他情况保留4位小数
return price.toFixed(4)
}
// 检查模型是否有任何能力
function hasAnyCapability(model: Model): boolean {
return !!(
(model.effective_supports_vision ?? model.supports_vision) ||
(model.effective_supports_function_calling ?? model.supports_function_calling) ||
(model.effective_supports_streaming ?? model.supports_streaming) ||
(model.effective_supports_extended_thinking ?? model.supports_extended_thinking) ||
(model.effective_supports_image_generation ?? model.supports_image_generation)
)
}
// 检查是否有按 Token 计费
function hasTokenPricing(model: Model): boolean {
const inputPrice = model.effective_input_price
const outputPrice = model.effective_output_price
return (inputPrice != null && inputPrice > 0) || (outputPrice != null && outputPrice > 0)
}
// 获取有效的缓存价格(从 effective_tiered_pricing 或 tiered_pricing 中提取)
function getEffectiveCachePrice(model: Model, type: 'creation' | 'read'): number {
const tiered = model.effective_tiered_pricing || model.tiered_pricing
if (!tiered?.tiers?.length) return 0
const firstTier = tiered.tiers[0]
if (type === 'creation') {
return firstTier.cache_creation_price_per_1m || 0
}
return firstTier.cache_read_price_per_1m || 0
}
// 获取 1h 缓存价格
function get1hCachePrice(model: Model): number {
const tiered = model.effective_tiered_pricing || model.tiered_pricing
if (!tiered?.tiers?.length) return 0
const firstTier = tiered.tiers[0]
const ttl1h = firstTier.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
return ttl1h?.cache_creation_price_per_1m || 0
}
// 检查是否有按次计费
function hasRequestPricing(model: Model): boolean {
const requestPrice = model.effective_price_per_request ?? model.price_per_request
return requestPrice != null && requestPrice > 0
}
// 获取状态指示灯样式
function getStatusIndicatorClass(model: Model): string {
if (!model.is_active) {
// 已停用 - 灰色
return 'bg-gray-400 dark:bg-gray-600'
}
if (model.is_available) {
// 活跃且可用 - 绿色
return 'bg-green-500 dark:bg-green-400'
}
// 活跃但不可用 - 红色
return 'bg-red-500 dark:bg-red-400'
}
// 获取状态提示文本
function getStatusTitle(model: Model): string {
if (!model.is_active) {
return '已停用'
}
if (model.is_available) {
return '活跃且可用'
}
return '活跃但不可用'
}
// 编辑模型
function editModel(model: Model) {
emit('edit-model', model)
}
// 删除模型
function deleteModel(model: Model) {
emit('delete-model', model)
}
// 打开批量关联对话框
function openBatchAssignDialog() {
emit('batch-assign')
}
// 切换模型启用状态
async function toggleModelActive(model: Model) {
if (togglingModelId.value) return
togglingModelId.value = model.id
try {
const newStatus = !model.is_active
await updateModel(props.provider.id, model.id, { is_active: newStatus })
model.is_active = newStatus
showSuccess(newStatus ? '模型已启用' : '模型已停用')
} catch (err: any) {
showError(err.response?.data?.detail || '操作失败', '错误')
} finally {
togglingModelId.value = null
}
}
onMounted(() => {
loadModels()
})
</script>

View File

@@ -0,0 +1 @@
export * from './components'