refactor(frontend): 优化功能模块组件

- 更新 api-keys 模块: StandaloneKeyFormDialog
- 改进 auth 模块: LoginDialog
- 优化 models 模块: AliasDialog, GlobalModelFormDialog, ModelDetailDrawer, TieredPricingEditor
- 重构 providers 模块: 多个表单和对话框组件
- 更新 usage 模块: 时间线、表格和详情组件
- 调整 users 模块: UserFormDialog
This commit is contained in:
fawney19
2025-12-12 16:15:36 +08:00
parent e9a6233655
commit 06c0a47b21
29 changed files with 2572 additions and 1051 deletions

View File

@@ -1,11 +1,11 @@
<template>
<Dialog
:model-value="open"
@update:model-value="$emit('update:open', $event)"
title="批量添加关联模型"
description="为提供商批量添加模型实现,提供商将自动继承模型的价格和能力,可在添加后单独修改"
:icon="Layers"
size="4xl"
@update:model-value="$emit('update:open', $event)"
>
<template #default>
<div class="space-y-4">
@@ -13,10 +13,17 @@
<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>
<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">
<Badge
variant="outline"
class="text-xs"
>
当前 {{ existingModels.length }} 个模型
</Badge>
</div>
@@ -28,7 +35,9 @@
<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>
<p class="text-sm font-medium">
可添加
</p>
<Button
v-if="availableModels.length > 0"
variant="ghost"
@@ -39,19 +48,33 @@
{{ isAllLeftSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge variant="secondary" class="text-xs">
<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">
<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">
<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>
<p class="text-sm">
所有模型均已关联
</p>
</div>
<div v-else class="p-2 space-y-1">
<div
v-else
class="p-2 space-y-1"
>
<div
v-for="model in availableModels"
:key="model.id"
@@ -67,8 +90,12 @@
@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>
<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'"
@@ -90,11 +117,18 @@
class="w-9 h-8"
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'border-primary' : ''"
:disabled="selectedLeftIds.length === 0 || submittingAdd"
@click="batchAddSelected"
title="添加选中"
@click="batchAddSelected"
>
<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' : ''" />
<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"
@@ -102,11 +136,18 @@
class="w-9 h-8"
:class="selectedRightIds.length > 0 && !submittingRemove ? 'border-primary' : ''"
:disabled="selectedRightIds.length === 0 || submittingRemove"
@click="batchRemoveSelected"
title="移除选中"
@click="batchRemoveSelected"
>
<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' : ''" />
<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>
@@ -114,7 +155,9 @@
<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>
<p class="text-sm font-medium">
已添加
</p>
<Button
v-if="existingModels.length > 0"
variant="ghost"
@@ -125,16 +168,27 @@
{{ isAllRightSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge variant="secondary" class="text-xs">
<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">
<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>
<p class="text-sm">
暂无关联模型
</p>
</div>
<div v-else class="p-2 space-y-1">
<div
v-else
class="p-2 space-y-1"
>
<div
v-for="model in existingModels"
:key="'existing-' + model.id"
@@ -150,8 +204,12 @@
@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>
<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'"

View File

@@ -7,11 +7,18 @@
size="xl"
@update:model-value="handleDialogUpdate"
>
<form @submit.prevent="handleSubmit" class="space-y-6">
<form
class="space-y-6"
@submit.prevent="handleSubmit"
>
<!-- API 配置 -->
<div class="space-y-4">
<h3 v-if="isEditMode" class="text-sm font-medium">API 配置</h3>
<h3
v-if="isEditMode"
class="text-sm font-medium"
>
API 配置
</h3>
<div class="grid grid-cols-2 gap-4">
<!-- API 格式 -->
@@ -24,10 +31,16 @@
disabled
class="bg-muted"
/>
<p class="text-xs text-muted-foreground">API 格式创建后不可修改</p>
<p class="text-xs text-muted-foreground">
API 格式创建后不可修改
</p>
</template>
<template v-else>
<Select v-model="form.api_format" v-model:open="selectOpen" required>
<Select
v-model="form.api_format"
v-model:open="selectOpen"
required
>
<SelectTrigger>
<SelectValue placeholder="请选择 API 格式" />
</SelectTrigger>
@@ -69,7 +82,9 @@
<!-- 请求配置 -->
<div class="space-y-4">
<h3 class="text-sm font-medium">请求配置</h3>
<h3 class="text-sm font-medium">
请求配置
</h3>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2">
@@ -117,21 +132,20 @@
</div>
</div>
</div>
</form>
<template #footer>
<Button
@click="handleCancel"
type="button"
variant="outline"
:disabled="loading"
@click="handleCancel"
>
取消
</Button>
<Button
@click="handleSubmit"
:disabled="loading || !form.base_url || (!isEditMode && !form.api_format)"
@click="handleSubmit"
>
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : '创建') }}
</Button>

View File

@@ -2,16 +2,26 @@
<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">
<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
side="top"
:side-offset="8"
class="max-w-xs"
>
<div class="text-xs whitespace-pre-line">
{{ segment.tooltip }}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -1,42 +1,74 @@
<template>
<Card variant="default" class="overflow-hidden">
<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>
<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">
<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>
<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" />
<RefreshButton
:loading="loading"
@click="refreshData"
/>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="p-6">
<div v-if="loadingMonitors" class="flex items-center justify-center py-12">
<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">
<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>
<p class="text-xs mt-1">
端点尚未产生请求记录
</p>
</div>
<div v-else class="space-y-3">
<div
v-else
class="space-y-3"
>
<div
v-for="monitor in monitors"
:key="monitor.api_format"
@@ -48,7 +80,10 @@
<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">
<Badge
variant="outline"
class="font-mono text-xs"
>
{{ monitor.api_format }}
</Badge>
<Badge
@@ -61,7 +96,10 @@
</div>
<!-- 提供商信息仅管理员可见 -->
<div v-if="showProviderInfo && 'provider_count' in monitor" class="text-xs text-muted-foreground">
<div
v-if="showProviderInfo && 'provider_count' in monitor"
class="text-xs text-muted-foreground"
>
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
</div>
</div>

View File

@@ -9,9 +9,14 @@
>
<div class="space-y-4 py-2">
<!-- 已选模型展示 -->
<div v-if="selectedModels.length > 0" class="space-y-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>
<div class="text-xs font-medium text-muted-foreground">
已选模型 ({{ selectedModels.length }})
</div>
<Button
type="button"
variant="ghost"
@@ -43,26 +48,40 @@
<!-- 模型列表区域 -->
<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">
<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>
<div
v-if="loadingModels"
class="flex flex-col items-center justify-center py-12 space-y-3"
>
<div class="animate-spin rounded-full h-8 w-8 border-2 border-primary/20 border-t-primary" />
<span class="text-xs text-muted-foreground">正在加载模型列表...</span>
</div>
<!-- 无模型 -->
<div v-else-if="availableModels.length === 0" class="flex flex-col items-center justify-center py-12 text-muted-foreground border border-dashed rounded-lg bg-muted/10">
<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-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"
@@ -86,7 +105,10 @@
<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">
<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>
@@ -101,9 +123,22 @@
<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" />
<Button
variant="outline"
class="h-9"
@click="handleCancel"
>
取消
</Button>
<Button
:disabled="saving"
class="h-9 min-w-[80px]"
@click="handleSave"
>
<Loader2
v-if="saving"
class="w-3.5 h-3.5 mr-1.5 animate-spin"
/>
{{ saving ? '保存中' : '保存配置' }}
</Button>
</div>

View File

@@ -7,203 +7,239 @@
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>
<form
class="space-y-5"
autocomplete="off"
@submit.prevent="handleSave"
>
<!-- 基本信息 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
基本信息
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<Label :for="apiKeyInputId">API 密钥 {{ editingKey ? '' : '*' }}</Label>
<Label :for="keyNameInputId">密钥名称 *</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"
:id="keyNameInputId"
v-model="form.name"
:name="keyNameFieldName"
required
placeholder="例如:主 Key、备用 Key 1"
maxlength="100"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
@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>
<Label for="rate_multiplier">成本倍率 *</Label>
<Input
id="note"
v-model="form.note"
placeholder="可选的备注信息"
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"
v-model="form.api_key"
:name="apiKeyFieldName"
: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="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 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 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>
<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 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"
<!-- 能力标签配置 -->
<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"
class="rounded"
@change="form.capabilities[cap.name] = !form.capabilities[cap.name]"
>
<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>
<span>{{ cap.display_name }}</span>
</label>
</div>
</form>
</div>
</form>
<template #footer>
<Button @click="handleCancel" variant="outline">取消</Button>
<Button @click="handleSave" :disabled="saving">
<Button
variant="outline"
@click="handleCancel"
>
取消
</Button>
<Button
:disabled="saving"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>

View File

@@ -12,8 +12,8 @@
<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"
: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'
@@ -25,8 +25,8 @@
</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"
: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'
@@ -41,7 +41,10 @@
<!-- 内容区域 -->
<div class="min-h-[420px]">
<!-- 提供商优先级 -->
<div v-show="activeMainTab === 'provider'" class="space-y-4">
<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" />
@@ -49,18 +52,24 @@
</div>
<!-- 空状态 -->
<div v-if="sortedProviders.length === 0" class="flex flex-col items-center justify-center py-20 text-muted-foreground">
<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-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"
: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
@@ -100,7 +109,10 @@
</div>
<!-- Key 优先级 -->
<div v-show="activeMainTab === 'key'" class="space-y-3">
<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" />
@@ -108,29 +120,38 @@
</div>
<!-- 加载状态 -->
<div v-if="loadingKeys" class="flex items-center justify-center py-20">
<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>
<div class="animate-spin rounded-full h-5 w-5 border-2 border-muted border-t-primary" />
<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">
<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">
<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"
: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'
@@ -143,116 +164,147 @@
<!-- 右侧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="format in availableFormats"
v-show="activeFormatTab === format"
:key="format"
>
<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)"
v-if="keysByFormat[format]?.length > 0"
class="space-y-2 max-h-[380px] overflow-y-auto pr-1"
>
<!-- 拖拽手柄 -->
<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
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"
:class="[
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>
<!-- 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 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"
autofocus
@blur="finishEditKeyPriority(format, key, $event)"
@keydown.enter="($event.target as HTMLInputElement).blur()"
@keydown.escape="cancelEditKeyPriority(format)"
>
<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>
<!-- 右侧健康度 + 速率 -->
<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) }}%
<!-- 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 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 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"
:class="[
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 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
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>
@@ -264,11 +316,24 @@
当前模式: <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" />
<Button
size="sm"
:disabled="saving"
class="min-w-[72px]"
@click="save"
>
<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
variant="outline"
size="sm"
class="min-w-[72px]"
@click="close"
>
取消
</Button>
</div>

View File

@@ -8,13 +8,19 @@
@click.self="handleBackdropClick"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="handleBackdropClick"></div>
<div
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
@click="handleBackdropClick"
/>
<!-- 抽屉内容 -->
<Card class="relative h-full w-[700px] rounded-none shadow-2xl overflow-y-auto">
<!-- 加载状态 -->
<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
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>
<template v-else-if="provider">
@@ -23,8 +29,13 @@
<div class="flex items-start justify-between gap-4">
<div class="space-y-1 flex-1 min-w-0">
<div class="flex items-center gap-2">
<h2 class="text-xl font-bold truncate">{{ provider.display_name }}</h2>
<Badge :variant="provider.is_active ? 'default' : 'secondary'" class="text-xs shrink-0">
<h2 class="text-xl font-bold truncate">
{{ provider.display_name }}
</h2>
<Badge
:variant="provider.is_active ? 'default' : 'secondary'"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '已停用' }}
</Badge>
</div>
@@ -45,18 +56,28 @@
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" @click="$emit('edit', provider)" title="编辑提供商">
<Button
variant="ghost"
size="icon"
title="编辑提供商"
@click="$emit('edit', provider)"
>
<Edit class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
@click="$emit('toggle-status', provider)"
:title="provider.is_active ? '点击停用' : '点击启用'"
@click="$emit('toggle-status', provider)"
>
<Power class="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" @click="handleClose" title="关闭">
<Button
variant="ghost"
size="icon"
title="关闭"
@click="handleClose"
>
<X class="w-4 h-4" />
</Button>
</div>
@@ -64,53 +85,72 @@
</div>
<div class="space-y-6 p-6">
<!-- 配额使用情况 -->
<Card v-if="provider.billing_type === 'monthly_quota' && provider.monthly_quota_usd" class="p-4">
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">订阅配额</h3>
<Badge variant="secondary" class="text-xs">
{{ ((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100).toFixed(1) }}%
</Badge>
</div>
<div class="relative w-full h-2 bg-muted rounded-full overflow-hidden">
<!-- 配额使用情况 -->
<Card
v-if="provider.billing_type === 'monthly_quota' && provider.monthly_quota_usd"
class="p-4"
>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
订阅配额
</h3>
<Badge
variant="secondary"
class="text-xs"
>
{{ ((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100).toFixed(1) }}%
</Badge>
</div>
<div class="relative w-full h-2 bg-muted rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="{
'bg-green-500': (provider.monthly_used_usd || 0) / provider.monthly_quota_usd < 0.7,
'bg-yellow-500': (provider.monthly_used_usd || 0) / provider.monthly_quota_usd >= 0.7 && (provider.monthly_used_usd || 0) / provider.monthly_quota_usd < 0.9,
'bg-red-500': (provider.monthly_used_usd || 0) / provider.monthly_quota_usd >= 0.9
}"
:style="{ width: `${Math.min((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100, 100)}%` }"
/>
</div>
<div class="flex items-center justify-between text-xs">
<span class="font-semibold">
${{ (provider.monthly_used_usd || 0).toFixed(2) }} / ${{ provider.monthly_quota_usd.toFixed(2) }}
</span>
<span
v-if="provider.quota_reset_day"
class="text-muted-foreground"
>
每月 {{ provider.quota_reset_day }} 号重置
</span>
</div>
</div>
</Card>
<!-- 端点与密钥管理 -->
<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">
<span>端点与密钥管理</span>
</h3>
<Button
variant="outline"
size="sm"
class="h-8"
@click="showAddEndpointDialog"
>
<Plus class="w-3.5 h-3.5 mr-1.5" />
添加端点
</Button>
</div>
</div>
<!-- 端点列表 -->
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="{
'bg-green-500': (provider.monthly_used_usd || 0) / provider.monthly_quota_usd < 0.7,
'bg-yellow-500': (provider.monthly_used_usd || 0) / provider.monthly_quota_usd >= 0.7 && (provider.monthly_used_usd || 0) / provider.monthly_quota_usd < 0.9,
'bg-red-500': (provider.monthly_used_usd || 0) / provider.monthly_quota_usd >= 0.9
}"
:style="{ width: `${Math.min((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100, 100)}%` }"
></div>
</div>
<div class="flex items-center justify-between text-xs">
<span class="font-semibold">
${{ (provider.monthly_used_usd || 0).toFixed(2) }} / ${{ provider.monthly_quota_usd.toFixed(2) }}
</span>
<span v-if="provider.quota_reset_day" class="text-muted-foreground">
每月 {{ provider.quota_reset_day }} 号重置
</span>
</div>
</div>
</Card>
<!-- 端点与密钥管理 -->
<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">
<span>端点与密钥管理</span>
</h3>
<Button @click="showAddEndpointDialog" variant="outline" size="sm" class="h-8">
<Plus class="w-3.5 h-3.5 mr-1.5" />
添加端点
</Button>
</div>
</div>
<!-- 端点列表 -->
<div v-if="endpoints.length > 0" class="divide-y divide-border/40">
v-if="endpoints.length > 0"
class="divide-y divide-border/40"
>
<div
v-for="endpoint in endpoints"
:key="endpoint.id"
@@ -141,10 +181,16 @@
<Key class="w-3 h-3" />
{{ endpoint.keys?.filter((k: EndpointAPIKey) => k.is_active).length || 0 }}
</span>
<span v-if="endpoint.max_retries" class="text-xs text-muted-foreground">
<span
v-if="endpoint.max_retries"
class="text-xs text-muted-foreground"
>
{{ endpoint.max_retries }}次重试
</span>
<span v-if="endpoint.timeout" class="text-xs text-muted-foreground">
<span
v-if="endpoint.timeout"
class="text-xs text-muted-foreground"
>
{{ endpoint.timeout }}s
</span>
</div>
@@ -156,61 +202,70 @@
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0"
@click.stop="copyToClipboard(endpoint.base_url)"
title="复制 Base URL"
@click.stop="copyToClipboard(endpoint.base_url)"
>
<Copy class="w-3 h-3" />
</Button>
</div>
</div>
</div>
<div class="flex items-center gap-1" @click.stop>
<div
class="flex items-center gap-1"
@click.stop
>
<Button
v-if="hasUnhealthyKeys(endpoint)"
@click="handleRecoverAllKeys(endpoint)"
variant="ghost"
size="icon"
class="h-8 w-8 text-green-600"
title="恢复所有密钥健康状态"
:disabled="recoveringEndpointId === endpoint.id"
@click="handleRecoverAllKeys(endpoint)"
>
<Loader2 v-if="recoveringEndpointId === endpoint.id" class="w-3.5 h-3.5 animate-spin" />
<RefreshCw v-else class="w-3.5 h-3.5" />
<Loader2
v-if="recoveringEndpointId === endpoint.id"
class="w-3.5 h-3.5 animate-spin"
/>
<RefreshCw
v-else
class="w-3.5 h-3.5"
/>
</Button>
<Button
@click="handleAddKey(endpoint)"
variant="ghost"
size="icon"
class="h-8 w-8"
title="添加密钥"
@click="handleAddKey(endpoint)"
>
<Plus class="w-4 h-4" />
</Button>
<Button
@click="handleEditEndpoint(endpoint)"
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑端点"
@click="handleEditEndpoint(endpoint)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
@click="toggleEndpointActive(endpoint)"
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="togglingEndpointId === endpoint.id"
:title="endpoint.is_active ? '点击停用' : '点击启用'"
@click="toggleEndpointActive(endpoint)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
@click="handleDeleteEndpoint(endpoint)"
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除端点"
@click="handleDeleteEndpoint(endpoint)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
@@ -225,7 +280,10 @@
>
<div class="space-y-3 pt-3">
<!-- 端点配置信息 -->
<div v-if="endpoint.custom_path || endpoint.rpm_limit" class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
<div
v-if="endpoint.custom_path || endpoint.rpm_limit"
class="flex flex-wrap gap-x-4 gap-y-1 text-xs"
>
<div v-if="endpoint.custom_path">
<span class="text-muted-foreground">自定义路径:</span>
<span class="ml-1 font-mono">{{ endpoint.custom_path }}</span>
@@ -238,16 +296,14 @@
<!-- 密钥列表 -->
<div class="space-y-2">
<div v-if="endpoint.keys && endpoint.keys.length > 0" class="space-y-2">
<div
v-if="endpoint.keys && endpoint.keys.length > 0"
class="space-y-2"
>
<div
v-for="key in endpoint.keys"
:key="key.id"
draggable="true"
@dragstart="handleDragStart($event, key, endpoint)"
@dragend="handleDragEnd"
@dragover="handleDragOver($event, key)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, key, endpoint)"
class="p-3 bg-background rounded-md border transition-all duration-150 group/key"
:class="{
'border-border/40 hover:border-border/80': dragState.targetKeyId !== key.id,
@@ -255,6 +311,11 @@
'opacity-50': dragState.draggedKeyId === key.id,
'cursor-grabbing': dragState.isDragging
}"
@dragstart="handleDragStart($event, key, endpoint)"
@dragend="handleDragEnd"
@dragover="handleDragOver($event, key)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, key, endpoint)"
>
<!-- 密钥主要信息行 -->
<div class="flex items-center justify-between mb-2">
@@ -276,7 +337,9 @@
{{ key.is_active ? '活跃' : '禁用' }}
</Badge>
</div>
<div class="text-[10px] font-mono text-muted-foreground truncate">{{ key.api_key_masked }}</div>
<div class="text-[10px] font-mono text-muted-foreground truncate">
{{ key.api_key_masked }}
</div>
</div>
<div class="flex items-center gap-1.5 ml-auto shrink-0">
<div
@@ -309,48 +372,48 @@
<div class="flex items-center gap-1 ml-2">
<Button
v-if="key.circuit_breaker_open || (key.health_score !== undefined && key.health_score < 0.5)"
@click="handleRecoverKey(key)"
variant="ghost"
size="icon"
class="h-7 w-7 text-green-600"
title="刷新健康状态"
@click="handleRecoverKey(key)"
>
<RefreshCw class="w-3 h-3" />
</Button>
<Button
@click="handleConfigKeyModels(key)"
variant="ghost"
size="icon"
class="h-7 w-7"
title="配置允许的模型"
@click="handleConfigKeyModels(key)"
>
<Layers class="w-3 h-3" />
</Button>
<Button
@click="handleEditKey(endpoint, key)"
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑密钥"
@click="handleEditKey(endpoint, key)"
>
<Edit class="w-3 h-3" />
</Button>
<Button
@click="toggleKeyActive(key)"
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="togglingKeyId === key.id"
:title="key.is_active ? '点击停用' : '点击启用'"
@click="toggleKeyActive(key)"
>
<Power class="w-3 h-3" />
</Button>
<Button
@click="handleDeleteKey(key)"
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除密钥"
@click="handleDeleteKey(key)"
>
<Trash2 class="w-3 h-3" />
</Button>
@@ -371,42 +434,67 @@
P {{ key.internal_priority }}
</span>
<!-- 编辑模式 -->
<span v-else class="flex items-center gap-1">
<span
v-else
class="flex items-center gap-1"
>
<span class="text-muted-foreground">P</span>
<input
type="number"
ref="priorityInput"
v-model.number="editingPriorityValue"
type="number"
class="w-12 h-5 px-1 text-[11px] border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary"
min="0"
@keyup.enter="savePriority(key, endpoint)"
@keyup.escape="cancelEditPriority"
@blur="savePriority(key, endpoint)"
ref="priorityInput"
/>
>
</span>
<span class="text-muted-foreground" title="成本倍率,实际成本 = 模型价格 × 倍率">
<span
class="text-muted-foreground"
title="成本倍率,实际成本 = 模型价格 × 倍率"
>
{{ key.rate_multiplier }}x
</span>
<span v-if="key.success_rate !== undefined" class="text-muted-foreground" title="成功率 = 成功次数 / 总请求数">
<span
v-if="key.success_rate !== undefined"
class="text-muted-foreground"
title="成功率 = 成功次数 / 总请求数"
>
{{ (key.success_rate * 100).toFixed(1) }}% ({{ key.success_count }}/{{ key.request_count }})
</span>
</div>
<!-- 右侧动态信息 -->
<div class="flex items-center gap-2 ml-auto">
<span v-if="key.next_probe_at" class="text-amber-600 dark:text-amber-400" title="熔断器探测恢复时间">
<span
v-if="key.next_probe_at"
class="text-amber-600 dark:text-amber-400"
title="熔断器探测恢复时间"
>
{{ formatProbeTime(key.next_probe_at) }}探测
</span>
<span v-if="key.rate_limit" class="text-muted-foreground" title="每分钟请求数限制">
<span
v-if="key.rate_limit"
class="text-muted-foreground"
title="每分钟请求数限制"
>
{{ key.rate_limit }}rpm
</span>
<span v-if="key.max_concurrent || key.is_adaptive" class="text-muted-foreground" :title="key.is_adaptive ? `自适应并发限制(学习值: ${key.learned_max_concurrent ?? '未学习'}` : '固定并发限制'">
<span
v-if="key.max_concurrent || key.is_adaptive"
class="text-muted-foreground"
:title="key.is_adaptive ? `自适应并发限制(学习值: ${key.learned_max_concurrent ?? '未学习'}` : '固定并发限制'"
>
{{ key.is_adaptive ? '自适应' : '固定' }}并发: {{ key.learned_max_concurrent || key.max_concurrent || 3 }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="text-xs text-muted-foreground text-center py-4">
<div
v-else
class="text-xs text-muted-foreground text-center py-4"
>
暂无密钥
</div>
</div>
@@ -416,32 +504,39 @@
</div>
<!-- 空状态 -->
<div v-else class="p-8 text-center text-muted-foreground">
<div
v-else
class="p-8 text-center text-muted-foreground"
>
<Server class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">暂无端点配置</p>
<p class="text-xs mt-1">点击上方"添加端点"按钮创建第一个端点</p>
<p class="text-sm">
暂无端点配置
</p>
<p class="text-xs mt-1">
点击上方"添加端点"按钮创建第一个端点
</p>
</div>
</Card>
</Card>
<!-- 模型查看 -->
<ModelsTab
v-if="provider"
:key="`models-${provider.id}`"
:provider="provider"
@edit-model="handleEditModel"
@delete-model="handleDeleteModel"
@batch-assign="handleBatchAssign"
/>
<!-- 模型查看 -->
<ModelsTab
v-if="provider"
:key="`models-${provider.id}`"
:provider="provider"
@edit-model="handleEditModel"
@delete-model="handleDeleteModel"
@batch-assign="handleBatchAssign"
/>
<!-- 模型映射 -->
<MappingsTab
v-if="provider"
:key="`mappings-${provider.id}`"
:provider="provider"
@refresh="handleRelatedDataRefresh"
/>
</div>
</template>
<!-- 模型映射 -->
<MappingsTab
v-if="provider"
:key="`mappings-${provider.id}`"
:provider="provider"
@refresh="handleRelatedDataRefresh"
/>
</div>
</template>
</Card>
</div>
</Transition>

View File

@@ -7,13 +7,21 @@
size="2xl"
@update:model-value="handleDialogUpdate"
>
<form @submit.prevent="handleSubmit" class="space-y-6">
<form
class="space-y-6"
@submit.prevent="handleSubmit"
>
<!-- 基本信息 -->
<div class="space-y-4">
<h3 class="text-sm font-medium border-b pb-2">基本信息</h3>
<h3 class="text-sm font-medium border-b pb-2">
基本信息
</h3>
<!-- 添加模式显示提供商标识 -->
<div v-if="!isEditMode" class="space-y-2">
<div
v-if="!isEditMode"
class="space-y-2"
>
<Label for="name">提供商标识 *</Label>
<Input
id="name"
@@ -21,7 +29,9 @@
placeholder="例如: openai-primary"
required
/>
<p class="text-xs text-muted-foreground">唯一ID创建后不可修改</p>
<p class="text-xs text-muted-foreground">
唯一ID创建后不可修改
</p>
</div>
<div class="grid grid-cols-2 gap-4">
@@ -58,18 +68,29 @@
<!-- 计费与限流 -->
<div class="space-y-4">
<h3 class="text-sm font-medium border-b pb-2">计费与限流</h3>
<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">
<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>
<SelectItem value="monthly_quota">
月卡额度
</SelectItem>
<SelectItem value="pay_as_you_go">
按量付费
</SelectItem>
<SelectItem value="free_tier">
免费套餐
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -86,7 +107,10 @@
</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
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
@@ -114,13 +138,25 @@
周期开始时间
<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>
<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>
<Input
v-model="form.quota_expires_at"
type="datetime-local"
class="h-9"
/>
<p class="text-xs text-muted-foreground">
留空表示永久有效
</p>
</div>
</div>
</div>
@@ -130,14 +166,14 @@
<Button
type="button"
variant="outline"
@click="handleCancel"
:disabled="loading"
@click="handleCancel"
>
取消
</Button>
<Button
@click="handleSubmit"
:disabled="loading || !form.display_name || (!isEditMode && !form.name)"
@click="handleSubmit"
>
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存' : '创建') }}
</Button>
@@ -147,16 +183,18 @@
<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 {
Dialog,
Button,
Input,
Textarea,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui'
import { Server, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'

View File

@@ -1,15 +1,21 @@
<template>
<Dialog
:model-value="open"
@update:model-value="handleClose"
:title="isEditing ? '编辑模型配置' : '添加模型'"
:description="isEditing ? '修改模型价格和能力配置' : '为此 Provider 添加模型实现'"
:icon="isEditing ? SquarePen : Layers"
size="xl"
@update:model-value="handleClose"
>
<form @submit.prevent="handleSubmit" class="space-y-4">
<form
class="space-y-4"
@submit.prevent="handleSubmit"
>
<!-- 添加模式选择全局模型 -->
<div v-if="!isEditing" class="space-y-2">
<div
v-if="!isEditing"
class="space-y-2"
>
<Label for="global-model">选择模型 *</Label>
<Select
v-model:open="globalModelSelectOpen"
@@ -30,25 +36,41 @@
</SelectItem>
</SelectContent>
</Select>
<p v-if="availableGlobalModels.length === 0 && !loadingGlobalModels" class="text-xs text-muted-foreground">
<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
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>
<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 ref="tieredPricingEditorRef" v-model="tieredPricing" :show-cache1h="showCache1h" />
<h4 class="font-semibold text-sm border-b pb-2">
价格配置
</h4>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="showCache1h"
/>
<!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t">
@@ -68,70 +90,80 @@
<!-- 能力配置 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm border-b pb-2">能力配置</h4>
<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"
type="checkbox"
: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"
type="checkbox"
: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"
type="checkbox"
: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"
type="checkbox"
: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"
type="checkbox"
: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
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" />
<Button
:disabled="submitting || (!isEditing && !form.global_model_id)"
@click="handleSubmit"
>
<Loader2
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
{{ isEditing ? '保存' : '添加' }}
</Button>
</template>
@@ -141,10 +173,17 @@
<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 {
Dialog,
Button,
Input,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseNumberInput } from '@/utils/form'
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'

View File

@@ -10,10 +10,10 @@
</div>
<Button
v-if="!hideAddButton"
@click="openCreateDialog"
variant="outline"
size="sm"
class="h-8"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5 mr-1.5" />
创建别名/映射
@@ -22,19 +22,36 @@
</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
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 v-else-if="mappings.length > 0" class="overflow-x-auto">
<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>
<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>
@@ -50,19 +67,25 @@
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">
<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">
<td
v-if="!hideAddButton"
class="px-4 py-3"
>
<div class="flex justify-center gap-1.5">
<Button
variant="ghost"
@@ -78,8 +101,8 @@
size="icon"
class="h-8 w-8"
:disabled="togglingId === mapping.id"
@click="toggleActive(mapping)"
:title="mapping.is_active ? '点击停用' : '点击启用'"
@click="toggleActive(mapping)"
>
<Power class="w-3.5 h-3.5" />
</Button>
@@ -100,10 +123,17 @@
</div>
<!-- 空状态 -->
<div v-else class="p-8 text-center text-muted-foreground">
<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>
<p class="text-sm">
暂无特定别名/映射
</p>
<p class="text-xs mt-1">
点击上方按钮添加
</p>
</div>
</Card>

View File

@@ -6,7 +6,12 @@
<h3 class="text-sm font-semibold flex items-center gap-2">
模型列表
</h3>
<Button @click="openBatchAssignDialog" variant="outline" size="sm" class="h-8">
<Button
variant="outline"
size="sm"
class="h-8"
@click="openBatchAssignDialog"
>
<Layers class="w-3.5 h-3.5 mr-1.5" />
关联模型
</Button>
@@ -14,19 +19,33 @@
</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
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 v-else-if="models.length > 0" class="overflow-hidden">
<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>
<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>
@@ -42,7 +61,7 @@
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">
@@ -51,9 +70,9 @@
<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"
@click.stop="copyModelId(model.provider_model_name)"
>
<Copy class="w-3 h-3" />
</button>
@@ -62,17 +81,46 @@
</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
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>
<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;">
<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>
@@ -112,8 +160,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="editModel(model)"
title="编辑"
@click="editModel(model)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
@@ -122,8 +170,8 @@
size="icon"
class="h-8 w-8"
:disabled="togglingModelId === model.id"
@click="toggleModelActive(model)"
:title="model.is_active ? '点击停用' : '点击启用'"
@click="toggleModelActive(model)"
>
<Power class="w-3.5 h-3.5" />
</Button>
@@ -131,8 +179,8 @@
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="deleteModel(model)"
title="删除"
@click="deleteModel(model)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
@@ -144,10 +192,17 @@
</div>
<!-- 空状态 -->
<div v-else class="p-8 text-center text-muted-foreground">
<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>
<p class="text-sm">
暂无模型
</p>
<p class="text-xs mt-1">
请前往"模型目录"页面添加模型
</p>
</div>
</Card>
</template>