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

@@ -8,8 +8,14 @@
<div class="border-b border-border px-6 py-4"> <div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0"> <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Plus v-if="!isEditMode" class="h-5 w-5 text-primary" /> <Plus
<SquarePen v-else class="h-5 w-5 text-primary" /> v-if="!isEditMode"
class="h-5 w-5 text-primary"
/>
<SquarePen
v-else
class="h-5 w-5 text-primary"
/>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight"> <h3 class="text-lg font-semibold text-foreground leading-tight">
@@ -33,7 +39,10 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="form-name" class="text-sm font-medium">密钥名称</Label> <Label
for="form-name"
class="text-sm font-medium"
>密钥名称</Label>
<Input <Input
id="form-name" id="form-name"
v-model="form.name" v-model="form.name"
@@ -44,8 +53,14 @@
</div> </div>
<!-- 初始余额 - 仅创建模式显示 --> <!-- 初始余额 - 仅创建模式显示 -->
<div v-if="!isEditMode" class="space-y-2"> <div
<Label for="form-balance" class="text-sm font-medium">初始余额 (USD) <span class="text-rose-500">*</span></Label> v-if="!isEditMode"
class="space-y-2"
>
<Label
for="form-balance"
class="text-sm font-medium"
>初始余额 (USD) <span class="text-rose-500">*</span></Label>
<Input <Input
id="form-balance" id="form-balance"
:model-value="form.initial_balance_usd ?? ''" :model-value="form.initial_balance_usd ?? ''"
@@ -57,11 +72,16 @@
class="h-10" class="h-10"
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true }) ?? 10" @update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true }) ?? 10"
/> />
<p class="text-xs text-muted-foreground">独立Key必须设置余额限制最小值 $0.01</p> <p class="text-xs text-muted-foreground">
独立Key必须设置余额限制最小值 $0.01
</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="form-expire-days" class="text-sm font-medium">有效期设置</Label> <Label
for="form-expire-days"
class="text-sm font-medium"
>有效期设置</Label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Input <Input
id="form-expire-days" id="form-expire-days"
@@ -80,24 +100,32 @@
type="checkbox" type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer" class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
@change="onNeverExpireChange" @change="onNeverExpireChange"
/> >
永不过期 永不过期
</label> </label>
<label class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap" :class="form.never_expire ? 'opacity-50' : ''"> <label
class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap"
:class="form.never_expire ? 'opacity-50' : ''"
>
<input <input
v-model="form.auto_delete_on_expiry" v-model="form.auto_delete_on_expiry"
type="checkbox" type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer" class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
:disabled="form.never_expire" :disabled="form.never_expire"
/> >
到期删除 到期删除
</label> </label>
</div> </div>
<p class="text-xs text-muted-foreground">不勾选"到期删除"则仅禁用</p> <p class="text-xs text-muted-foreground">
不勾选"到期删除"则仅禁用
</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="form-rate-limit" class="text-sm font-medium">速率限制 (请求/分钟)</Label> <Label
for="form-rate-limit"
class="text-sm font-medium"
>速率限制 (请求/分钟)</Label>
<Input <Input
id="form-rate-limit" id="form-rate-limit"
:model-value="form.rate_limit ?? ''" :model-value="form.rate_limit ?? ''"
@@ -131,9 +159,16 @@
<span :class="form.allowed_providers.length ? 'text-foreground' : 'text-muted-foreground'"> <span :class="form.allowed_providers.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_providers.length ? `已选择 ${form.allowed_providers.length} 个` : '全部可用' }} {{ form.allowed_providers.length ? `已选择 ${form.allowed_providers.length} 个` : '全部可用' }}
</span> </span>
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="providerDropdownOpen ? 'rotate-180' : ''" /> <ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="providerDropdownOpen ? 'rotate-180' : ''"
/>
</button> </button>
<div v-if="providerDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="providerDropdownOpen = false"></div> <div
v-if="providerDropdownOpen"
class="fixed inset-0 z-[80]"
@click.stop="providerDropdownOpen = false"
/>
<div <div
v-if="providerDropdownOpen" v-if="providerDropdownOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto" class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
@@ -150,10 +185,13 @@
class="h-4 w-4 rounded border-gray-300 cursor-pointer" class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop @click.stop
@change="toggleSelection('allowed_providers', provider.id)" @change="toggleSelection('allowed_providers', provider.id)"
/> >
<span class="text-sm">{{ provider.display_name || provider.name }}</span> <span class="text-sm">{{ provider.display_name || provider.name }}</span>
</div> </div>
<div v-if="providers.length === 0" class="px-3 py-2 text-sm text-muted-foreground"> <div
v-if="providers.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
暂无可用 Provider 暂无可用 Provider
</div> </div>
</div> </div>
@@ -172,9 +210,16 @@
<span :class="form.allowed_api_formats.length ? 'text-foreground' : 'text-muted-foreground'"> <span :class="form.allowed_api_formats.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_api_formats.length ? `已选择 ${form.allowed_api_formats.length} 个` : '全部可用' }} {{ form.allowed_api_formats.length ? `已选择 ${form.allowed_api_formats.length} 个` : '全部可用' }}
</span> </span>
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="apiFormatDropdownOpen ? 'rotate-180' : ''" /> <ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="apiFormatDropdownOpen ? 'rotate-180' : ''"
/>
</button> </button>
<div v-if="apiFormatDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="apiFormatDropdownOpen = false"></div> <div
v-if="apiFormatDropdownOpen"
class="fixed inset-0 z-[80]"
@click.stop="apiFormatDropdownOpen = false"
/>
<div <div
v-if="apiFormatDropdownOpen" v-if="apiFormatDropdownOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto" class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
@@ -191,7 +236,7 @@
class="h-4 w-4 rounded border-gray-300 cursor-pointer" class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop @click.stop
@change="toggleSelection('allowed_api_formats', format)" @change="toggleSelection('allowed_api_formats', format)"
/> >
<span class="text-sm">{{ format }}</span> <span class="text-sm">{{ format }}</span>
</div> </div>
</div> </div>
@@ -210,9 +255,16 @@
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'"> <span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }} {{ form.allowed_models.length ? `已选择 ${form.allowed_models.length} 个` : '全部可用' }}
</span> </span>
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="modelDropdownOpen ? 'rotate-180' : ''" /> <ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="modelDropdownOpen ? 'rotate-180' : ''"
/>
</button> </button>
<div v-if="modelDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="modelDropdownOpen = false"></div> <div
v-if="modelDropdownOpen"
class="fixed inset-0 z-[80]"
@click.stop="modelDropdownOpen = false"
/>
<div <div
v-if="modelDropdownOpen" v-if="modelDropdownOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto" class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
@@ -229,10 +281,13 @@
class="h-4 w-4 rounded border-gray-300 cursor-pointer" class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop @click.stop
@change="toggleSelection('allowed_models', model.name)" @change="toggleSelection('allowed_models', model.name)"
/> >
<span class="text-sm">{{ model.name }}</span> <span class="text-sm">{{ model.name }}</span>
</div> </div>
<div v-if="globalModels.length === 0" class="px-3 py-2 text-sm text-muted-foreground"> <div
v-if="globalModels.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
暂无可用模型 暂无可用模型
</div> </div>
</div> </div>
@@ -243,8 +298,19 @@
</form> </form>
<template #footer> <template #footer>
<Button variant="outline" @click="handleCancel" type="button" class="h-10 px-5">取消</Button> <Button
<Button @click="handleSubmit" :disabled="saving" class="h-10 px-5"> variant="outline"
type="button"
class="h-10 px-5"
@click="handleCancel"
>
取消
</Button>
<Button
:disabled="saving"
class="h-10 px-5"
@click="handleSubmit"
>
{{ saving ? (isEditMode ? '更新中...' : '创建中...') : (isEditMode ? '更新' : '创建') }} {{ saving ? (isEditMode ? '更新中...' : '创建中...') : (isEditMode ? '更新' : '创建') }}
</Button> </Button>
</template> </template>

View File

@@ -1,10 +1,17 @@
<template> <template>
<Dialog v-model="isOpen" size="lg"> <Dialog
v-model="isOpen"
size="lg"
>
<div class="space-y-6"> <div class="space-y-6">
<!-- Logo 和标题 --> <!-- Logo 和标题 -->
<div class="flex flex-col items-center text-center"> <div class="flex flex-col items-center text-center">
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10"> <div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
<img src="/aether_adaptive.svg" alt="Logo" class="h-16 w-16" /> <img
src="/aether_adaptive.svg"
alt="Logo"
class="h-16 w-16"
>
</div> </div>
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white"> <h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
欢迎回来 欢迎回来
@@ -12,11 +19,22 @@
</div> </div>
<!-- Demo 模式提示 --> <!-- Demo 模式提示 -->
<div v-if="isDemo" class="rounded-lg border border-primary/20 dark:border-primary/30 bg-primary/5 dark:bg-primary/10 p-4"> <div
v-if="isDemo"
class="rounded-lg border border-primary/20 dark:border-primary/30 bg-primary/5 dark:bg-primary/10 p-4"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="flex-shrink-0 text-primary dark:text-primary/90"> <div class="flex-shrink-0 text-primary dark:text-primary/90">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" /> class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -29,16 +47,16 @@
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
<button <button
type="button" type="button"
@click="fillDemoAccount('admin')"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group" class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group"
@click="fillDemoAccount('admin')"
> >
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-primary/20 dark:bg-primary/30 text-primary text-[10px] font-bold group-hover:bg-primary/30 dark:group-hover:bg-primary/40 transition-colors">A</span> <span class="inline-flex items-center justify-center w-5 h-5 rounded bg-primary/20 dark:bg-primary/30 text-primary text-[10px] font-bold group-hover:bg-primary/30 dark:group-hover:bg-primary/40 transition-colors">A</span>
<span>管理员admin@demo.aether.io / demo123</span> <span>管理员admin@demo.aether.io / demo123</span>
</button> </button>
<button <button
type="button" type="button"
@click="fillDemoAccount('user')"
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group" class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group"
@click="fillDemoAccount('user')"
> >
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-muted text-muted-foreground text-[10px] font-bold group-hover:bg-muted/80 transition-colors">U</span> <span class="inline-flex items-center justify-center w-5 h-5 rounded bg-muted text-muted-foreground text-[10px] font-bold group-hover:bg-muted/80 transition-colors">U</span>
<span>普通用户user@demo.aether.io / demo123</span> <span>普通用户user@demo.aether.io / demo123</span>
@@ -49,7 +67,10 @@
</div> </div>
<!-- 登录表单 --> <!-- 登录表单 -->
<form @submit.prevent="handleLogin" class="space-y-4"> <form
class="space-y-4"
@submit.prevent="handleLogin"
>
<div class="space-y-2"> <div class="space-y-2">
<Label for="login-email">邮箱</Label> <Label for="login-email">邮箱</Label>
<Input <Input
@@ -76,7 +97,10 @@
</div> </div>
<!-- 提示信息 --> <!-- 提示信息 -->
<p v-if="!isDemo" class="text-xs text-slate-400 dark:text-muted-foreground/80"> <p
v-if="!isDemo"
class="text-xs text-slate-400 dark:text-muted-foreground/80"
>
如需开通账户请联系管理员配置访问权限 如需开通账户请联系管理员配置访问权限
</p> </p>
</form> </form>
@@ -84,17 +108,17 @@
<template #footer> <template #footer>
<Button <Button
@click="isOpen = false"
type="button" type="button"
variant="outline" variant="outline"
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10" class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
@click="isOpen = false"
> >
取消 取消
</Button> </Button>
<Button <Button
@click="handleLogin"
:disabled="authStore.loading" :disabled="authStore.loading"
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0" class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
@click="handleLogin"
> >
{{ authStore.loading ? '登录中...' : '登录' }} {{ authStore.loading ? '登录中...' : '登录' }}
</Button> </Button>

View File

@@ -1,42 +1,56 @@
<template> <template>
<Dialog <Dialog
:model-value="open" :model-value="open"
@update:model-value="handleDialogUpdate"
:title="dialogTitle" :title="dialogTitle"
:description="dialogDescription" :description="dialogDescription"
:icon="dialogIcon" :icon="dialogIcon"
size="md" size="md"
@update:model-value="handleDialogUpdate"
>
<form
class="space-y-4"
@submit.prevent="handleSubmit"
> >
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- 模式选择仅创建时显示 --> <!-- 模式选择仅创建时显示 -->
<div v-if="!isEditMode" class="space-y-2"> <div
v-if="!isEditMode"
class="space-y-2"
>
<Label>创建类型 *</Label> <Label>创建类型 *</Label>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
@click="form.mapping_type = 'alias'" class="p-3 rounded-lg border-2 text-left transition-all"
:class="[ :class="[
'p-3 rounded-lg border-2 text-left transition-all',
form.mapping_type === 'alias' form.mapping_type === 'alias'
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50' : 'border-border hover:border-primary/50'
]" ]"
@click="form.mapping_type = 'alias'"
> >
<div class="font-medium text-sm">别名</div> <div class="font-medium text-sm">
<div class="text-xs text-muted-foreground mt-1">名称简写按目标模型计费</div> 别名
</div>
<div class="text-xs text-muted-foreground mt-1">
名称简写按目标模型计费
</div>
</button> </button>
<button <button
type="button" type="button"
@click="form.mapping_type = 'mapping'" class="p-3 rounded-lg border-2 text-left transition-all"
:class="[ :class="[
'p-3 rounded-lg border-2 text-left transition-all',
form.mapping_type === 'mapping' form.mapping_type === 'mapping'
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50' : 'border-border hover:border-primary/50'
]" ]"
@click="form.mapping_type = 'mapping'"
> >
<div class="font-medium text-sm">映射</div> <div class="font-medium text-sm">
<div class="text-xs text-muted-foreground mt-1">模型降级按源模型计费</div> 映射
</div>
<div class="text-xs text-muted-foreground mt-1">
模型降级按源模型计费
</div>
</button> </button>
</div> </div>
</div> </div>
@@ -54,20 +68,37 @@
</div> </div>
<!-- Provider 选择/作用范围 --> <!-- Provider 选择/作用范围 -->
<div v-if="showProviderSelect" class="space-y-2"> <div
v-if="showProviderSelect"
class="space-y-2"
>
<Label>作用范围</Label> <Label>作用范围</Label>
<!-- 固定 Provider 时显示只读 --> <!-- 固定 Provider 时显示只读 -->
<div v-if="fixedProvider" class="px-3 py-2 border rounded-md bg-muted/50 text-sm"> <div
v-if="fixedProvider"
class="px-3 py-2 border rounded-md bg-muted/50 text-sm"
>
{{ fixedProvider.display_name || fixedProvider.name }} {{ fixedProvider.display_name || fixedProvider.name }}
</div> </div>
<!-- 否则显示可选择的下拉 --> <!-- 否则显示可选择的下拉 -->
<Select v-else v-model:open="providerSelectOpen" :model-value="form.provider_id || 'global'" @update:model-value="handleProviderChange"> <Select
v-else
v-model:open="providerSelectOpen"
:model-value="form.provider_id || 'global'"
@update:model-value="handleProviderChange"
>
<SelectTrigger class="w-full"> <SelectTrigger class="w-full">
<SelectValue placeholder="选择作用范围" /> <SelectValue placeholder="选择作用范围" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="global">全局所有 Provider</SelectItem> <SelectItem value="global">
<SelectItem v-for="p in providers" :key="p.id" :value="p.id"> 全局所有 Provider
</SelectItem>
<SelectItem
v-for="p in providers"
:key="p.id"
:value="p.id"
>
{{ p.display_name || p.name }} {{ p.display_name || p.name }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -75,7 +106,10 @@
</div> </div>
<!-- 别名模式别名名称 --> <!-- 别名模式别名名称 -->
<div v-if="form.mapping_type === 'alias'" class="space-y-2"> <div
v-if="form.mapping_type === 'alias'"
class="space-y-2"
>
<Label for="alias-name">别名名称 *</Label> <Label for="alias-name">别名名称 *</Label>
<Input <Input
id="alias-name" id="alias-name"
@@ -90,10 +124,21 @@
</div> </div>
<!-- 映射模式选择源模型 --> <!-- 映射模式选择源模型 -->
<div v-else class="space-y-2"> <div
v-else
class="space-y-2"
>
<Label>源模型 (用户请求的模型) *</Label> <Label>源模型 (用户请求的模型) *</Label>
<Select v-model:open="sourceModelSelectOpen" :model-value="form.alias" @update:model-value="form.alias = $event" :disabled="isEditMode"> <Select
<SelectTrigger class="w-full" :class="{ 'opacity-50': isEditMode }"> v-model:open="sourceModelSelectOpen"
:model-value="form.alias"
:disabled="isEditMode"
@update:model-value="form.alias = $event"
>
<SelectTrigger
class="w-full"
:class="{ 'opacity-50': isEditMode }"
>
<SelectValue placeholder="请选择源模型" /> <SelectValue placeholder="请选择源模型" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -117,12 +162,20 @@
{{ form.mapping_type === 'alias' ? '目标模型 *' : '目标模型 (实际处理请求) *' }} {{ form.mapping_type === 'alias' ? '目标模型 *' : '目标模型 (实际处理请求) *' }}
</Label> </Label>
<!-- 固定目标模型时显示只读信息 --> <!-- 固定目标模型时显示只读信息 -->
<div v-if="fixedTargetModel" class="px-3 py-2 border rounded-md bg-muted/50"> <div
v-if="fixedTargetModel"
class="px-3 py-2 border rounded-md bg-muted/50"
>
<span class="font-medium">{{ fixedTargetModel.display_name }}</span> <span class="font-medium">{{ fixedTargetModel.display_name }}</span>
<span class="text-muted-foreground ml-1">({{ fixedTargetModel.name }})</span> <span class="text-muted-foreground ml-1">({{ fixedTargetModel.name }})</span>
</div> </div>
<!-- 否则显示下拉选择 --> <!-- 否则显示下拉选择 -->
<Select v-else v-model:open="targetModelSelectOpen" :model-value="form.global_model_id" @update:model-value="form.global_model_id = $event"> <Select
v-else
v-model:open="targetModelSelectOpen"
:model-value="form.global_model_id"
@update:model-value="form.global_model_id = $event"
>
<SelectTrigger class="w-full"> <SelectTrigger class="w-full">
<SelectValue placeholder="请选择模型" /> <SelectValue placeholder="请选择模型" />
</SelectTrigger> </SelectTrigger>
@@ -137,15 +190,24 @@
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</form> </form>
<template #footer> <template #footer>
<Button type="button" variant="outline" @click="handleCancel"> <Button
type="button"
variant="outline"
@click="handleCancel"
>
取消 取消
</Button> </Button>
<Button @click="handleSubmit" :disabled="submitting"> <Button
<Loader2 v-if="submitting" class="w-4 h-4 mr-2 animate-spin" /> :disabled="submitting"
@click="handleSubmit"
>
<Loader2
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
{{ isEditMode ? '保存' : '创建' }} {{ isEditMode ? '保存' : '创建' }}
</Button> </Button>
</template> </template>

View File

@@ -7,14 +7,22 @@
size="xl" size="xl"
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<form @submit.prevent="handleSubmit" class="space-y-5 max-h-[70vh] overflow-y-auto pr-1"> <form
class="space-y-5 max-h-[70vh] overflow-y-auto pr-1"
@submit.prevent="handleSubmit"
>
<!-- 基本信息 --> <!-- 基本信息 -->
<section class="space-y-3"> <section class="space-y-3">
<h4 class="font-medium text-sm">基本信息</h4> <h4 class="font-medium text-sm">
基本信息
</h4>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="model-name" class="text-xs">模型名称 *</Label> <Label
for="model-name"
class="text-xs"
>模型名称 *</Label>
<Input <Input
id="model-name" id="model-name"
v-model="form.name" v-model="form.name"
@@ -22,10 +30,18 @@
:disabled="isEditMode" :disabled="isEditMode"
required required
/> />
<p v-if="!isEditMode" class="text-xs text-muted-foreground">创建后不可修改</p> <p
v-if="!isEditMode"
class="text-xs text-muted-foreground"
>
创建后不可修改
</p>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="model-display-name" class="text-xs">显示名称 *</Label> <Label
for="model-display-name"
class="text-xs"
>显示名称 *</Label>
<Input <Input
id="model-display-name" id="model-display-name"
v-model="form.display_name" v-model="form.display_name"
@@ -36,7 +52,10 @@
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="model-description" class="text-xs">描述</Label> <Label
for="model-description"
class="text-xs"
>描述</Label>
<Input <Input
id="model-description" id="model-description"
v-model="form.description" v-model="form.description"
@@ -47,30 +66,52 @@
<!-- 能力配置 --> <!-- 能力配置 -->
<section class="space-y-2"> <section class="space-y-2">
<h4 class="font-medium text-sm">默认能力</h4> <h4 class="font-medium text-sm">
默认能力
</h4>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"> <label 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" v-model="form.default_supports_streaming" class="rounded" /> <input
v-model="form.default_supports_streaming"
type="checkbox"
class="rounded"
>
<Zap class="w-3.5 h-3.5 text-muted-foreground" /> <Zap class="w-3.5 h-3.5 text-muted-foreground" />
<span>流式输出</span> <span>流式输出</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"> <label 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" v-model="form.default_supports_vision" class="rounded" /> <input
v-model="form.default_supports_vision"
type="checkbox"
class="rounded"
>
<Eye class="w-3.5 h-3.5 text-muted-foreground" /> <Eye class="w-3.5 h-3.5 text-muted-foreground" />
<span>视觉理解</span> <span>视觉理解</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"> <label 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" v-model="form.default_supports_function_calling" class="rounded" /> <input
v-model="form.default_supports_function_calling"
type="checkbox"
class="rounded"
>
<Wrench class="w-3.5 h-3.5 text-muted-foreground" /> <Wrench class="w-3.5 h-3.5 text-muted-foreground" />
<span>工具调用</span> <span>工具调用</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"> <label 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" v-model="form.default_supports_extended_thinking" class="rounded" /> <input
v-model="form.default_supports_extended_thinking"
type="checkbox"
class="rounded"
>
<Brain class="w-3.5 h-3.5 text-muted-foreground" /> <Brain class="w-3.5 h-3.5 text-muted-foreground" />
<span>深度思考</span> <span>深度思考</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"> <label 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" v-model="form.default_supports_image_generation" class="rounded" /> <input
v-model="form.default_supports_image_generation"
type="checkbox"
class="rounded"
>
<Image class="w-3.5 h-3.5 text-muted-foreground" /> <Image class="w-3.5 h-3.5 text-muted-foreground" />
<span>图像生成</span> <span>图像生成</span>
</label> </label>
@@ -78,8 +119,13 @@
</section> </section>
<!-- Key 能力配置 --> <!-- Key 能力配置 -->
<section v-if="availableCapabilities.length > 0" class="space-y-2"> <section
<h4 class="font-medium text-sm">Key 能力支持</h4> v-if="availableCapabilities.length > 0"
class="space-y-2"
>
<h4 class="font-medium text-sm">
Key 能力支持
</h4>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<label <label
v-for="cap in availableCapabilities" v-for="cap in availableCapabilities"
@@ -89,9 +135,9 @@
<input <input
type="checkbox" type="checkbox"
:checked="form.supported_capabilities?.includes(cap.name)" :checked="form.supported_capabilities?.includes(cap.name)"
@change="toggleCapability(cap.name)"
class="rounded" class="rounded"
/> @change="toggleCapability(cap.name)"
>
<span>{{ cap.display_name }}</span> <span>{{ cap.display_name }}</span>
</label> </label>
</div> </div>
@@ -99,8 +145,14 @@
<!-- 价格配置 --> <!-- 价格配置 -->
<section class="space-y-3"> <section class="space-y-3">
<h4 class="font-medium text-sm">价格配置</h4> <h4 class="font-medium text-sm">
<TieredPricingEditor ref="tieredPricingEditorRef" v-model="tieredPricing" :show-cache1h="form.supported_capabilities?.includes('cache_1h')" /> 价格配置
</h4>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="form.supported_capabilities?.includes('cache_1h')"
/>
<!-- 按次计费 --> <!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t"> <div class="flex items-center gap-3 pt-2 border-t">
@@ -120,11 +172,21 @@
</form> </form>
<template #footer> <template #footer>
<Button type="button" variant="outline" @click="handleCancel"> <Button
type="button"
variant="outline"
@click="handleCancel"
>
取消 取消
</Button> </Button>
<Button @click="handleSubmit" :disabled="submitting"> <Button
<Loader2 v-if="submitting" class="w-4 h-4 mr-2 animate-spin" /> :disabled="submitting"
@click="handleSubmit"
>
<Loader2
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
{{ isEditMode ? '保存' : '创建' }} {{ isEditMode ? '保存' : '创建' }}
</Button> </Button>
</template> </template>
@@ -138,6 +200,7 @@ import { Dialog, Button, Input, Label } from '@/components/ui'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog' import { useFormDialog } from '@/composables/useFormDialog'
import { parseNumberInput } from '@/utils/form' import { parseNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
import TieredPricingEditor from './TieredPricingEditor.vue' import TieredPricingEditor from './TieredPricingEditor.vue'
import { import {
createGlobalModel, createGlobalModel,
@@ -204,7 +267,7 @@ async function loadCapabilities() {
try { try {
availableCapabilities.value = await getAllCapabilities() availableCapabilities.value = await getAllCapabilities()
} catch (err) { } catch (err) {
console.error('Failed to load capabilities:', err) log.error('Failed to load capabilities:', err)
} }
} }

View File

@@ -8,7 +8,10 @@
@click.self="handleBackdropClick" @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"> <Card class="relative h-full w-[700px] rounded-none shadow-2xl overflow-y-auto">
@@ -16,8 +19,13 @@
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="space-y-1 flex-1 min-w-0"> <div class="space-y-1 flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3 class="text-xl font-bold truncate">{{ model.display_name }}</h3> <h3 class="text-xl font-bold truncate">
<Badge :variant="model.is_active ? 'default' : 'secondary'" class="text-xs shrink-0"> {{ model.display_name }}
</h3>
<Badge
:variant="model.is_active ? 'default' : 'secondary'"
class="text-xs shrink-0"
>
{{ model.is_active ? '活跃' : '停用' }} {{ model.is_active ? '活跃' : '停用' }}
</Badge> </Badge>
</div> </div>
@@ -32,23 +40,36 @@
</button> </button>
<template v-if="model.description"> <template v-if="model.description">
<span class="shrink-0">·</span> <span class="shrink-0">·</span>
<span class="text-xs truncate" :title="model.description">{{ model.description }}</span> <span
class="text-xs truncate"
:title="model.description"
>{{ model.description }}</span>
</template> </template>
</div> </div>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" @click="$emit('edit-model', model)" title="编辑模型"> <Button
variant="ghost"
size="icon"
title="编辑模型"
@click="$emit('edit-model', model)"
>
<Edit class="w-4 h-4" /> <Edit class="w-4 h-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@click="$emit('toggle-model-status', model)"
:title="model.is_active ? '点击停用' : '点击启用'" :title="model.is_active ? '点击停用' : '点击启用'"
@click="$emit('toggle-model-status', model)"
> >
<Power class="w-4 h-4" /> <Power class="w-4 h-4" />
</Button> </Button>
<Button variant="ghost" size="icon" @click="handleClose" title="关闭"> <Button
variant="ghost"
size="icon"
title="关闭"
@click="handleClose"
>
<X class="w-4 h-4" /> <X class="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -60,8 +81,8 @@
<div class="flex gap-1 p-1 bg-muted/40 rounded-lg mb-4"> <div class="flex gap-1 p-1 bg-muted/40 rounded-lg mb-4">
<button <button
type="button" type="button"
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="[ :class="[
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
detailTab === 'basic' detailTab === 'basic'
? 'bg-primary text-primary-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50' : 'text-muted-foreground hover:text-foreground hover:bg-background/50'
@@ -72,8 +93,8 @@
</button> </button>
<button <button
type="button" type="button"
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="[ :class="[
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
detailTab === 'providers' detailTab === 'providers'
? 'bg-primary text-primary-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50' : 'text-muted-foreground hover:text-foreground hover:bg-background/50'
@@ -84,8 +105,8 @@
</button> </button>
<button <button
type="button" type="button"
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200"
:class="[ :class="[
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
detailTab === 'aliases' detailTab === 'aliases'
? 'bg-primary text-primary-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50' : 'text-muted-foreground hover:text-foreground hover:bg-background/50'
@@ -97,69 +118,113 @@
</div> </div>
<!-- Tab 内容 --> <!-- Tab 内容 -->
<div v-show="detailTab === 'basic'" class="space-y-6"> <div
v-show="detailTab === 'basic'"
class="space-y-6"
>
<!-- 基础属性 --> <!-- 基础属性 -->
<div class="space-y-4"> <div class="space-y-4">
<h4 class="font-semibold text-sm">基础属性</h4> <h4 class="font-semibold text-sm">
基础属性
</h4>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<Label class="text-xs text-muted-foreground">创建时间</Label> <Label class="text-xs text-muted-foreground">创建时间</Label>
<p class="text-sm mt-1">{{ formatDate(model.created_at) }}</p> <p class="text-sm mt-1">
{{ formatDate(model.created_at) }}
</p>
</div> </div>
</div> </div>
</div> </div>
<!-- 模型能力 --> <!-- 模型能力 -->
<div class="space-y-3"> <div class="space-y-3">
<h4 class="font-semibold text-sm">模型能力</h4> <h4 class="font-semibold text-sm">
模型能力
</h4>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
<Zap class="w-5 h-5 text-muted-foreground" /> <Zap class="w-5 h-5 text-muted-foreground" />
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium">Streaming</p> <p class="text-sm font-medium">
<p class="text-xs text-muted-foreground">流式输出</p> Streaming
</p>
<p class="text-xs text-muted-foreground">
流式输出
</p>
</div> </div>
<Badge :variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'" class="text-xs"> <Badge
:variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }} {{ model.default_supports_streaming ?? false ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
<Image class="w-5 h-5 text-muted-foreground" /> <Image class="w-5 h-5 text-muted-foreground" />
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium">Image Generation</p> <p class="text-sm font-medium">
<p class="text-xs text-muted-foreground">图像生成</p> Image Generation
</p>
<p class="text-xs text-muted-foreground">
图像生成
</p>
</div> </div>
<Badge :variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'" class="text-xs"> <Badge
:variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }} {{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
<Eye class="w-5 h-5 text-muted-foreground" /> <Eye class="w-5 h-5 text-muted-foreground" />
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium">Vision</p> <p class="text-sm font-medium">
<p class="text-xs text-muted-foreground">视觉理解</p> Vision
</p>
<p class="text-xs text-muted-foreground">
视觉理解
</p>
</div> </div>
<Badge :variant="model.default_supports_vision ?? false ? 'default' : 'secondary'" class="text-xs"> <Badge
:variant="model.default_supports_vision ?? false ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }} {{ model.default_supports_vision ?? false ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
<Wrench class="w-5 h-5 text-muted-foreground" /> <Wrench class="w-5 h-5 text-muted-foreground" />
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium">Tool Use</p> <p class="text-sm font-medium">
<p class="text-xs text-muted-foreground">工具调用</p> Tool Use
</p>
<p class="text-xs text-muted-foreground">
工具调用
</p>
</div> </div>
<Badge :variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'" class="text-xs"> <Badge
:variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }} {{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
<Brain class="w-5 h-5 text-muted-foreground" /> <Brain class="w-5 h-5 text-muted-foreground" />
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium">Extended Thinking</p> <p class="text-sm font-medium">
<p class="text-xs text-muted-foreground">深度思考</p> Extended Thinking
</p>
<p class="text-xs text-muted-foreground">
深度思考
</p>
</div> </div>
<Badge :variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'" class="text-xs"> <Badge
:variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }} {{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
@@ -167,8 +232,13 @@
</div> </div>
<!-- 模型偏好 --> <!-- 模型偏好 -->
<div v-if="model.supported_capabilities && model.supported_capabilities.length > 0" class="space-y-3"> <div
<h4 class="font-semibold text-sm">模型偏好</h4> v-if="model.supported_capabilities && model.supported_capabilities.length > 0"
class="space-y-3"
>
<h4 class="font-semibold text-sm">
模型偏好
</h4>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Badge <Badge
v-for="cap in model.supported_capabilities" v-for="cap in model.supported_capabilities"
@@ -183,10 +253,15 @@
<!-- 默认定价 --> <!-- 默认定价 -->
<div class="space-y-3"> <div class="space-y-3">
<h4 class="font-semibold text-sm">默认定价</h4> <h4 class="font-semibold text-sm">
默认定价
</h4>
<!-- 单阶梯固定价格展示 --> <!-- 单阶梯固定价格展示 -->
<div v-if="getTierCount(model.default_tiered_pricing) <= 1" class="space-y-3"> <div
v-if="getTierCount(model.default_tiered_pricing) <= 1"
class="space-y-3"
>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<!-- Token 计费 --> <!-- Token 计费 -->
<div class="p-3 rounded-lg border"> <div class="p-3 rounded-lg border">
@@ -215,19 +290,28 @@
</div> </div>
</div> </div>
<!-- 1h 缓存 --> <!-- 1h 缓存 -->
<div v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"> <div
v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'"
class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"
>
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存创建</Label> <Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存创建</Label>
<span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span> <span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span>
</div> </div>
<!-- 按次计费 --> <!-- 按次计费 -->
<div v-if="model.default_price_per_request && model.default_price_per_request > 0" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"> <div
v-if="model.default_price_per_request && model.default_price_per_request > 0"
class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"
>
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label> <Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/</span> <span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/</span>
</div> </div>
</div> </div>
<!-- 多阶梯计费展示 --> <!-- 多阶梯计费展示 -->
<div v-else class="space-y-3"> <div
v-else
class="space-y-3"
>
<div class="flex items-center gap-2 text-sm text-muted-foreground"> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<Layers class="w-4 h-4" /> <Layers class="w-4 h-4" />
<span>阶梯计费 ({{ getTierCount(model.default_tiered_pricing) }} )</span> <span>阶梯计费 ({{ getTierCount(model.default_tiered_pricing) }} )</span>
@@ -238,12 +322,24 @@
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow class="bg-muted/30"> <TableRow class="bg-muted/30">
<TableHead class="text-xs h-9">阶梯</TableHead> <TableHead class="text-xs h-9">
<TableHead class="text-xs h-9 text-right">输入 ($/M)</TableHead> 阶梯
<TableHead class="text-xs h-9 text-right">输出 ($/M)</TableHead> </TableHead>
<TableHead class="text-xs h-9 text-right">缓存创建</TableHead> <TableHead class="text-xs h-9 text-right">
<TableHead class="text-xs h-9 text-right">缓存读取</TableHead> 输入 ($/M)
<TableHead class="text-xs h-9 text-right">1h 缓存</TableHead> </TableHead>
<TableHead class="text-xs h-9 text-right">
输出 ($/M)
</TableHead>
<TableHead class="text-xs h-9 text-right">
缓存创建
</TableHead>
<TableHead class="text-xs h-9 text-right">
缓存读取
</TableHead>
<TableHead class="text-xs h-9 text-right">
1h 缓存
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -253,7 +349,10 @@
class="text-xs" class="text-xs"
> >
<TableCell class="py-2"> <TableCell class="py-2">
<span v-if="tier.up_to === null" class="text-muted-foreground"> <span
v-if="tier.up_to === null"
class="text-muted-foreground"
>
{{ index === 0 ? '所有' : `> ${formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to)}` }} {{ index === 0 ? '所有' : `> ${formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to)}` }}
</span> </span>
<span v-else> <span v-else>
@@ -281,7 +380,10 @@
</div> </div>
<!-- 按次计费多阶梯时也显示 --> <!-- 按次计费多阶梯时也显示 -->
<div v-if="model.default_price_per_request && model.default_price_per_request > 0" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"> <div
v-if="model.default_price_per_request && model.default_price_per_request > 0"
class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"
>
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label> <Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/</span> <span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/</span>
</div> </div>
@@ -290,21 +392,27 @@
<!-- 统计信息 --> <!-- 统计信息 -->
<div class="space-y-3"> <div class="space-y-3">
<h4 class="font-semibold text-sm">统计信息</h4> <h4 class="font-semibold text-sm">
统计信息
</h4>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="p-3 rounded-lg border bg-muted/20"> <div class="p-3 rounded-lg border bg-muted/20">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">关联提供商</Label> <Label class="text-xs text-muted-foreground">关联提供商</Label>
<Building2 class="w-4 h-4 text-muted-foreground" /> <Building2 class="w-4 h-4 text-muted-foreground" />
</div> </div>
<p class="text-2xl font-bold mt-1">{{ model.provider_count || 0 }}</p> <p class="text-2xl font-bold mt-1">
{{ model.provider_count || 0 }}
</p>
</div> </div>
<div class="p-3 rounded-lg border bg-muted/20"> <div class="p-3 rounded-lg border bg-muted/20">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">别名数量</Label> <Label class="text-xs text-muted-foreground">别名数量</Label>
<Tag class="w-4 h-4 text-muted-foreground" /> <Tag class="w-4 h-4 text-muted-foreground" />
</div> </div>
<p class="text-2xl font-bold mt-1">{{ model.alias_count || 0 }}</p> <p class="text-2xl font-bold mt-1">
{{ model.alias_count || 0 }}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -317,15 +425,17 @@
<div class="px-4 py-3 border-b border-border/60"> <div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h4 class="text-sm font-semibold">关联提供商列表</h4> <h4 class="text-sm font-semibold">
关联提供商列表
</h4>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@click="$emit('add-provider')"
title="添加关联" title="添加关联"
@click="$emit('add-provider')"
> >
<Plus class="w-3.5 h-3.5" /> <Plus class="w-3.5 h-3.5" />
</Button> </Button>
@@ -333,27 +443,41 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@click="$emit('refresh-providers')"
title="刷新" title="刷新"
@click="$emit('refresh-providers')"
> >
<RefreshCw class="w-3.5 h-3.5" :class="loadingProviders ? 'animate-spin' : ''" /> <RefreshCw
class="w-3.5 h-3.5"
:class="loadingProviders ? 'animate-spin' : ''"
/>
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<!-- 表格内容 --> <!-- 表格内容 -->
<div v-if="loadingProviders" class="flex items-center justify-center py-12"> <div
v-if="loadingProviders"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" /> <Loader2 class="w-6 h-6 animate-spin text-primary" />
</div> </div>
<Table v-else-if="providers.length > 0"> <Table v-else-if="providers.length > 0">
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-10 font-semibold">Provider</TableHead> <TableHead class="h-10 font-semibold">
<TableHead class="w-[120px] h-10 font-semibold">能力</TableHead> Provider
<TableHead class="w-[180px] h-10 font-semibold">价格 ($/M)</TableHead> </TableHead>
<TableHead class="w-[80px] h-10 font-semibold text-center">操作</TableHead> <TableHead class="w-[120px] h-10 font-semibold">
能力
</TableHead>
<TableHead class="w-[180px] h-10 font-semibold">
价格 ($/M)
</TableHead>
<TableHead class="w-[80px] h-10 font-semibold text-center">
操作
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -368,15 +492,27 @@
class="w-2 h-2 rounded-full shrink-0" class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'" :class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="provider.is_active ? '活跃' : '停用'" :title="provider.is_active ? '活跃' : '停用'"
></span> />
<span class="font-medium truncate">{{ provider.display_name }}</span> <span class="font-medium truncate">{{ provider.display_name }}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell class="py-3"> <TableCell class="py-3">
<div class="flex gap-0.5"> <div class="flex gap-0.5">
<Zap v-if="provider.supports_streaming" class="w-3.5 h-3.5 text-muted-foreground" title="流式输出" /> <Zap
<Eye v-if="provider.supports_vision" class="w-3.5 h-3.5 text-muted-foreground" title="视觉理解" /> v-if="provider.supports_streaming"
<Wrench v-if="provider.supports_function_calling" class="w-3.5 h-3.5 text-muted-foreground" title="工具调用" /> class="w-3.5 h-3.5 text-muted-foreground"
title="流式输出"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
title="工具调用"
/>
</div> </div>
</TableCell> </TableCell>
<TableCell class="py-3"> <TableCell class="py-3">
@@ -386,15 +522,25 @@
<span class="text-muted-foreground">输入/输出:</span> <span class="text-muted-foreground">输入/输出:</span>
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span> <span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
<!-- 阶梯标记 --> <!-- 阶梯标记 -->
<span v-if="(provider.tier_count || 1) > 1" class="ml-1 text-muted-foreground" title="阶梯计费">[阶梯]</span> <span
v-if="(provider.tier_count || 1) > 1"
class="ml-1 text-muted-foreground"
title="阶梯计费"
>[阶梯]</span>
</div> </div>
<!-- 缓存价格 --> <!-- 缓存价格 -->
<div v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0" class="text-muted-foreground"> <div
v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>缓存:</span> <span>缓存:</span>
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span> <span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
</div> </div>
<!-- 1h 缓存价格 --> <!-- 1h 缓存价格 -->
<div v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0" class="text-muted-foreground"> <div
v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>1h 缓存:</span> <span>1h 缓存:</span>
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span> <span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
</div> </div>
@@ -404,7 +550,10 @@
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/</span> <span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/</span>
</div> </div>
<!-- 无定价 --> <!-- 无定价 -->
<span v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)" class="text-muted-foreground">-</span> <span
v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)"
class="text-muted-foreground"
>-</span>
</div> </div>
</TableCell> </TableCell>
<TableCell class="py-3 text-center"> <TableCell class="py-3 text-center">
@@ -413,8 +562,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
@click="$emit('edit-provider', provider)"
title="编辑此关联" title="编辑此关联"
@click="$emit('edit-provider', provider)"
> >
<Edit class="w-3.5 h-3.5" /> <Edit class="w-3.5 h-3.5" />
</Button> </Button>
@@ -422,8 +571,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
@click="$emit('toggle-provider-status', provider)"
:title="provider.is_active ? '停用此关联' : '启用此关联'" :title="provider.is_active ? '停用此关联' : '启用此关联'"
@click="$emit('toggle-provider-status', provider)"
> >
<Power class="w-3.5 h-3.5" /> <Power class="w-3.5 h-3.5" />
</Button> </Button>
@@ -431,8 +580,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
@click="$emit('delete-provider', provider)"
title="删除此关联" title="删除此关联"
@click="$emit('delete-provider', provider)"
> >
<Trash2 class="w-3.5 h-3.5" /> <Trash2 class="w-3.5 h-3.5" />
</Button> </Button>
@@ -443,10 +592,20 @@
</Table> </Table>
<!-- 空状态 --> <!-- 空状态 -->
<div v-else class="text-center py-12"> <div
v-else
class="text-center py-12"
>
<Building2 class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" /> <Building2 class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground">暂无关联提供商</p> <p class="text-sm text-muted-foreground">
<Button size="sm" variant="outline" class="mt-4" @click="$emit('add-provider')"> 暂无关联提供商
</p>
<Button
size="sm"
variant="outline"
class="mt-4"
@click="$emit('add-provider')"
>
<Plus class="w-4 h-4 mr-1" /> <Plus class="w-4 h-4 mr-1" />
添加第一个关联 添加第一个关联
</Button> </Button>
@@ -461,15 +620,17 @@
<div class="px-4 py-3 border-b border-border/60"> <div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h4 class="text-sm font-semibold">别名与映射</h4> <h4 class="text-sm font-semibold">
别名与映射
</h4>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@click="$emit('add-alias')"
title="添加别名/映射" title="添加别名/映射"
@click="$emit('add-alias')"
> >
<Plus class="w-3.5 h-3.5" /> <Plus class="w-3.5 h-3.5" />
</Button> </Button>
@@ -477,27 +638,41 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@click="$emit('refresh-aliases')"
title="刷新" title="刷新"
@click="$emit('refresh-aliases')"
> >
<RefreshCw class="w-3.5 h-3.5" :class="loadingAliases ? 'animate-spin' : ''" /> <RefreshCw
class="w-3.5 h-3.5"
:class="loadingAliases ? 'animate-spin' : ''"
/>
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<!-- 表格内容 --> <!-- 表格内容 -->
<div v-if="loadingAliases" class="flex items-center justify-center py-12"> <div
v-if="loadingAliases"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" /> <Loader2 class="w-6 h-6 animate-spin text-primary" />
</div> </div>
<Table v-else-if="aliases.length > 0"> <Table v-else-if="aliases.length > 0">
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-10 font-semibold">别名</TableHead> <TableHead class="h-10 font-semibold">
<TableHead class="w-[80px] h-10 font-semibold">类型</TableHead> 别名
<TableHead class="w-[100px] h-10 font-semibold">作用域</TableHead> </TableHead>
<TableHead class="w-[100px] h-10 font-semibold text-center">操作</TableHead> <TableHead class="w-[80px] h-10 font-semibold">
类型
</TableHead>
<TableHead class="w-[100px] h-10 font-semibold">
作用域
</TableHead>
<TableHead class="w-[100px] h-10 font-semibold text-center">
操作
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -512,12 +687,15 @@
class="w-2 h-2 rounded-full shrink-0" class="w-2 h-2 rounded-full shrink-0"
:class="alias.is_active ? 'bg-green-500' : 'bg-gray-300'" :class="alias.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="alias.is_active ? '活跃' : '停用'" :title="alias.is_active ? '活跃' : '停用'"
></span> />
<code class="text-sm font-medium bg-muted px-1.5 py-0.5 rounded">{{ alias.alias }}</code> <code class="text-sm font-medium bg-muted px-1.5 py-0.5 rounded">{{ alias.alias }}</code>
</div> </div>
</TableCell> </TableCell>
<TableCell class="py-3"> <TableCell class="py-3">
<Badge variant="secondary" class="text-xs"> <Badge
variant="secondary"
class="text-xs"
>
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }} {{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
</Badge> </Badge>
</TableCell> </TableCell>
@@ -530,7 +708,13 @@
> >
{{ alias.provider_name || 'Provider' }} {{ alias.provider_name || 'Provider' }}
</Badge> </Badge>
<Badge v-else variant="default" class="text-xs">全局</Badge> <Badge
v-else
variant="default"
class="text-xs"
>
全局
</Badge>
</TableCell> </TableCell>
<TableCell class="py-3 text-center"> <TableCell class="py-3 text-center">
<div class="flex items-center justify-center gap-0.5"> <div class="flex items-center justify-center gap-0.5">
@@ -538,8 +722,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
@click="$emit('edit-alias', alias)"
title="编辑" title="编辑"
@click="$emit('edit-alias', alias)"
> >
<Edit class="w-3.5 h-3.5" /> <Edit class="w-3.5 h-3.5" />
</Button> </Button>
@@ -547,8 +731,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
@click="$emit('toggle-alias-status', alias)"
:title="alias.is_active ? '停用' : '启用'" :title="alias.is_active ? '停用' : '启用'"
@click="$emit('toggle-alias-status', alias)"
> >
<Power class="w-3.5 h-3.5" /> <Power class="w-3.5 h-3.5" />
</Button> </Button>
@@ -556,8 +740,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
@click="$emit('delete-alias', alias)"
title="删除" title="删除"
@click="$emit('delete-alias', alias)"
> >
<Trash2 class="w-3.5 h-3.5" /> <Trash2 class="w-3.5 h-3.5" />
</Button> </Button>
@@ -568,10 +752,20 @@
</Table> </Table>
<!-- 空状态 --> <!-- 空状态 -->
<div v-else class="text-center py-12"> <div
v-else
class="text-center py-12"
>
<Tag class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" /> <Tag class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground">暂无别名或映射</p> <p class="text-sm text-muted-foreground">
<Button size="sm" variant="outline" class="mt-4" @click="$emit('add-alias')"> 暂无别名或映射
</p>
<Button
size="sm"
variant="outline"
class="mt-4"
@click="$emit('add-alias')"
>
<Plus class="w-4 h-4 mr-1" /> <Plus class="w-4 h-4 mr-1" />
添加别名/映射 添加别名/映射
</Button> </Button>
@@ -607,6 +801,26 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
const props = withDefaults(defineProps<Props>(), {
loadingProviders: false,
loadingAliases: false,
hasBlockingDialogOpen: false
})
const emit = defineEmits<{
'update:open': [value: boolean]
'edit-model': [model: GlobalModelResponse]
'toggle-model-status': [model: GlobalModelResponse]
'add-provider': []
'edit-provider': [provider: any]
'delete-provider': [provider: any]
'toggle-provider-status': [provider: any]
'refresh-providers': []
'add-alias': []
'edit-alias': [alias: ModelAlias]
'toggle-alias-status': [alias: ModelAlias]
'delete-alias': [alias: ModelAlias]
'refresh-aliases': []
}>()
const { success: showSuccess, error: showError } = useToast() const { success: showSuccess, error: showError } = useToast()
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue' import Badge from '@/components/ui/badge.vue'
@@ -636,34 +850,12 @@ interface Props {
capabilities?: CapabilityDefinition[] capabilities?: CapabilityDefinition[]
} }
const props = withDefaults(defineProps<Props>(), {
loadingProviders: false,
loadingAliases: false,
hasBlockingDialogOpen: false
})
// 根据能力名称获取显示名称 // 根据能力名称获取显示名称
function getCapabilityDisplayName(capName: string): string { function getCapabilityDisplayName(capName: string): string {
const cap = props.capabilities?.find(c => c.name === capName) const cap = props.capabilities?.find(c => c.name === capName)
return cap?.display_name || capName return cap?.display_name || capName
} }
const emit = defineEmits<{
'update:open': [value: boolean]
'edit-model': [model: GlobalModelResponse]
'toggle-model-status': [model: GlobalModelResponse]
'add-provider': []
'edit-provider': [provider: any]
'delete-provider': [provider: any]
'toggle-provider-status': [provider: any]
'refresh-providers': []
'add-alias': []
'edit-alias': [alias: ModelAlias]
'toggle-alias-status': [alias: ModelAlias]
'delete-alias': [alias: ModelAlias]
'refresh-aliases': []
}>()
const detailTab = ref('basic') const detailTab = ref('basic')
// 处理背景点击 // 处理背景点击

View File

@@ -39,7 +39,10 @@
</option> </option>
</select> </select>
</template> </template>
<span v-else class="font-medium">无上限</span> <span
v-else
class="font-medium"
>无上限</span>
</div> </div>
<Button <Button
v-if="localTiers.length > 1" v-if="localTiers.length > 1"
@@ -53,7 +56,10 @@
</div> </div>
<!-- 价格输入 --> <!-- 价格输入 -->
<div :class="['grid gap-3', showCache1h ? 'grid-cols-5' : 'grid-cols-4']"> <div
class="grid gap-3"
:class="[showCache1h ? 'grid-cols-5' : 'grid-cols-4']"
>
<div class="space-y-1"> <div class="space-y-1">
<Label class="text-xs">输入 ($/M)</Label> <Label class="text-xs">输入 ($/M)</Label>
<Input <Input
@@ -102,7 +108,10 @@
@update:model-value="(v) => updateCacheRead(index, v)" @update:model-value="(v) => updateCacheRead(index, v)"
/> />
</div> </div>
<div v-if="showCache1h" class="space-y-1"> <div
v-if="showCache1h"
class="space-y-1"
>
<Label class="text-xs text-muted-foreground">1h 缓存创建</Label> <Label class="text-xs text-muted-foreground">1h 缓存创建</Label>
<Input <Input
:model-value="getCache1hDisplay(index)" :model-value="getCache1hDisplay(index)"
@@ -129,7 +138,10 @@
</Button> </Button>
<!-- 验证提示 --> <!-- 验证提示 -->
<p v-if="validationError" class="text-xs text-destructive"> <p
v-if="validationError"
class="text-xs text-destructive"
>
{{ validationError }} {{ validationError }}
</p> </p>
</div> </div>

View File

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

View File

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

View File

@@ -2,16 +2,26 @@
<div class="w-full space-y-1"> <div class="w-full space-y-1">
<!-- 时间线 --> <!-- 时间线 -->
<div class="flex items-center gap-px h-6 w-full"> <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> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<div <div
class="flex-1 h-full rounded-sm transition-all duration-150 cursor-pointer hover:scale-y-110 hover:brightness-110" class="flex-1 h-full rounded-sm transition-all duration-150 cursor-pointer hover:scale-y-110 hover:brightness-110"
:class="segment.color" :class="segment.color"
></div> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" :side-offset="8" class="max-w-xs"> <TooltipContent
<div class="text-xs whitespace-pre-line">{{ segment.tooltip }}</div> side="top"
:side-offset="8"
class="max-w-xs"
>
<div class="text-xs whitespace-pre-line">
{{ segment.tooltip }}
</div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View File

@@ -1,42 +1,74 @@
<template> <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="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <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"> <div class="flex items-center gap-3">
<Label class="text-xs text-muted-foreground">回溯时间</Label> <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"> <SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">1 小时</SelectItem> <SelectItem value="1">
<SelectItem value="6">6 小时</SelectItem> 1 小时
<SelectItem value="12">12 小时</SelectItem> </SelectItem>
<SelectItem value="24">24 小时</SelectItem> <SelectItem value="6">
<SelectItem value="48">48 小时</SelectItem> 6 小时
</SelectItem>
<SelectItem value="12">
12 小时
</SelectItem>
<SelectItem value="24">
24 小时
</SelectItem>
<SelectItem value="48">
48 小时
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<RefreshButton :loading="loading" @click="refreshData" /> <RefreshButton
:loading="loading"
@click="refreshData"
/>
</div> </div>
</div> </div>
</div> </div>
<!-- 内容区域 --> <!-- 内容区域 -->
<div class="p-6"> <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" /> <Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-muted-foreground">加载中...</span> <span class="ml-2 text-muted-foreground">加载中...</span>
</div> </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" /> <Activity class="w-12 h-12 mb-3 opacity-30" />
<p>暂无健康监控数据</p> <p>暂无健康监控数据</p>
<p class="text-xs mt-1">端点尚未产生请求记录</p> <p class="text-xs mt-1">
端点尚未产生请求记录
</p>
</div> </div>
<div v-else class="space-y-3"> <div
v-else
class="space-y-3"
>
<div <div
v-for="monitor in monitors" v-for="monitor in monitors"
:key="monitor.api_format" :key="monitor.api_format"
@@ -48,7 +80,10 @@
<div class="w-44 flex-shrink-0 space-y-1.5"> <div class="w-44 flex-shrink-0 space-y-1.5">
<!-- API 格式标签和成功率 --> <!-- API 格式标签和成功率 -->
<div class="flex items-center gap-2"> <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 }} {{ monitor.api_format }}
</Badge> </Badge>
<Badge <Badge
@@ -61,7 +96,10 @@
</div> </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 }} 个密钥 {{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
</div> </div>
</div> </div>

View File

@@ -9,9 +9,14 @@
> >
<div class="space-y-4 py-2"> <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="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 <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -43,26 +48,40 @@
<!-- 模型列表区域 --> <!-- 模型列表区域 -->
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between px-1"> <div class="flex items-center justify-between px-1">
<div class="text-xs font-medium text-muted-foreground">可选模型列表</div> <div class="text-xs font-medium text-muted-foreground">
<div v-if="!loadingModels && availableModels.length > 0" class="text-[10px] text-muted-foreground/60"> 可选模型列表
</div>
<div
v-if="!loadingModels && availableModels.length > 0"
class="text-[10px] text-muted-foreground/60"
>
{{ availableModels.length }} 个模型 {{ availableModels.length }} 个模型
</div> </div>
</div> </div>
<!-- 加载状态 --> <!-- 加载状态 -->
<div v-if="loadingModels" class="flex flex-col items-center justify-center py-12 space-y-3"> <div
<div class="animate-spin rounded-full h-8 w-8 border-2 border-primary/20 border-t-primary"></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> <span class="text-xs text-muted-foreground">正在加载模型列表...</span>
</div> </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" /> <Box class="w-10 h-10 mb-2 opacity-20" />
<span class="text-sm">暂无可选模型</span> <span class="text-sm">暂无可选模型</span>
</div> </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 <div
v-for="model in availableModels" v-for="model in availableModels"
:key="model.global_model_name" :key="model.global_model_name"
@@ -86,7 +105,10 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium truncate text-foreground/90">{{ model.display_name }}</span> <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) }} {{ formatPricingShort(model) }}
</span> </span>
</div> </div>
@@ -101,9 +123,22 @@
<template #footer> <template #footer>
<div class="flex items-center justify-end gap-2 w-full pt-2"> <div class="flex items-center justify-end gap-2 w-full pt-2">
<Button @click="handleCancel" variant="outline" class="h-9">取消</Button> <Button
<Button @click="handleSave" :disabled="saving" class="h-9 min-w-[80px]"> variant="outline"
<Loader2 v-if="saving" class="w-3.5 h-3.5 mr-1.5 animate-spin" /> 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 ? '保存中' : '保存配置' }} {{ saving ? '保存中' : '保存配置' }}
</Button> </Button>
</div> </div>

View File

@@ -7,17 +7,23 @@
size="2xl" size="2xl"
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<form @submit.prevent="handleSave" class="space-y-5" autocomplete="off"> <form
class="space-y-5"
autocomplete="off"
@submit.prevent="handleSave"
>
<!-- 基本信息 --> <!-- 基本信息 -->
<div class="space-y-3"> <div class="space-y-3">
<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="grid grid-cols-2 gap-4">
<div> <div>
<Label :for="keyNameInputId">密钥名称 *</Label> <Label :for="keyNameInputId">密钥名称 *</Label>
<Input <Input
:id="keyNameInputId" :id="keyNameInputId"
:name="keyNameFieldName"
v-model="form.name" v-model="form.name"
:name="keyNameFieldName"
required required
placeholder="例如:主 Key、备用 Key 1" placeholder="例如:主 Key、备用 Key 1"
maxlength="100" maxlength="100"
@@ -51,8 +57,8 @@
<Label :for="apiKeyInputId">API 密钥 {{ editingKey ? '' : '*' }}</Label> <Label :for="apiKeyInputId">API 密钥 {{ editingKey ? '' : '*' }}</Label>
<Input <Input
:id="apiKeyInputId" :id="apiKeyInputId"
:name="apiKeyFieldName"
v-model="form.api_key" v-model="form.api_key"
:name="apiKeyFieldName"
:type="apiKeyInputType" :type="apiKeyInputType"
:required="!editingKey" :required="!editingKey"
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'" :placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
@@ -67,10 +73,16 @@
@focus="apiKeyFocused = true" @focus="apiKeyFocused = true"
@blur="apiKeyFocused = form.api_key.trim().length > 0" @blur="apiKeyFocused = form.api_key.trim().length > 0"
/> />
<p v-if="apiKeyError" class="text-xs text-destructive mt-1"> <p
v-if="apiKeyError"
class="text-xs text-destructive mt-1"
>
{{ apiKeyError }} {{ apiKeyError }}
</p> </p>
<p v-else-if="editingKey" class="text-xs text-muted-foreground mt-1"> <p
v-else-if="editingKey"
class="text-xs text-muted-foreground mt-1"
>
留空表示不修改输入新值则覆盖 留空表示不修改输入新值则覆盖
</p> </p>
</div> </div>
@@ -87,7 +99,9 @@
<!-- 调度与限流 --> <!-- 调度与限流 -->
<div class="space-y-3"> <div class="space-y-3">
<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="grid grid-cols-2 gap-4">
<div> <div>
<Label for="internal_priority">内部优先级</Label> <Label for="internal_priority">内部优先级</Label>
@@ -97,7 +111,9 @@
type="number" type="number"
min="0" min="0"
/> />
<p class="text-xs text-muted-foreground mt-1">数字越小越优先</p> <p class="text-xs text-muted-foreground mt-1">
数字越小越优先
</p>
</div> </div>
<div> <div>
<Label for="max_concurrent">最大并发</Label> <Label for="max_concurrent">最大并发</Label>
@@ -109,7 +125,9 @@
placeholder="留空启用自适应" placeholder="留空启用自适应"
@update:model-value="(v) => form.max_concurrent = parseNumberInput(v)" @update:model-value="(v) => form.max_concurrent = parseNumberInput(v)"
/> />
<p class="text-xs text-muted-foreground mt-1">留空 = 自适应模式</p> <p class="text-xs text-muted-foreground mt-1">
留空 = 自适应模式
</p>
</div> </div>
</div> </div>
@@ -149,7 +167,9 @@
<!-- 缓存与熔断 --> <!-- 缓存与熔断 -->
<div class="space-y-3"> <div class="space-y-3">
<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="grid grid-cols-2 gap-4">
<div> <div>
<Label for="cache_ttl_minutes">缓存 TTL (分钟)</Label> <Label for="cache_ttl_minutes">缓存 TTL (分钟)</Label>
@@ -161,7 +181,9 @@
max="60" max="60"
@update:model-value="(v) => form.cache_ttl_minutes = parseNumberInput(v, { min: 0, max: 60 }) ?? 5" @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> <p class="text-xs text-muted-foreground mt-1">
0 = 禁用缓存亲和性
</p>
</div> </div>
<div> <div>
<Label for="max_probe_interval_minutes">熔断探测间隔 (分钟)</Label> <Label for="max_probe_interval_minutes">熔断探测间隔 (分钟)</Label>
@@ -174,14 +196,21 @@
placeholder="32" placeholder="32"
@update:model-value="(v) => form.max_probe_interval_minutes = parseNumberInput(v, { min: 2, max: 32 }) ?? 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> <p class="text-xs text-muted-foreground mt-1">
范围 2-32 分钟
</p>
</div> </div>
</div> </div>
</div> </div>
<!-- 能力标签配置 --> <!-- 能力标签配置 -->
<div v-if="availableCapabilities.length > 0" class="space-y-3"> <div
<h3 class="text-sm font-medium border-b pb-2">能力标签</h3> 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"> <div class="flex flex-wrap gap-2">
<label <label
v-for="cap in availableCapabilities" v-for="cap in availableCapabilities"
@@ -191,19 +220,26 @@
<input <input
type="checkbox" type="checkbox"
:checked="form.capabilities[cap.name] || false" :checked="form.capabilities[cap.name] || false"
@change="form.capabilities[cap.name] = !form.capabilities[cap.name]"
class="rounded" class="rounded"
/> @change="form.capabilities[cap.name] = !form.capabilities[cap.name]"
>
<span>{{ cap.display_name }}</span> <span>{{ cap.display_name }}</span>
</label> </label>
</div> </div>
</div> </div>
</form> </form>
<template #footer> <template #footer>
<Button @click="handleCancel" variant="outline">取消</Button> <Button
<Button @click="handleSave" :disabled="saving"> variant="outline"
@click="handleCancel"
>
取消
</Button>
<Button
:disabled="saving"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存' }} {{ saving ? '保存中...' : '保存' }}
</Button> </Button>
</template> </template>

View File

@@ -12,8 +12,8 @@
<div class="flex gap-1 p-1 bg-muted/40 rounded-lg"> <div class="flex gap-1 p-1 bg-muted/40 rounded-lg">
<button <button
type="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="[ :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' activeMainTab === 'provider'
? 'bg-primary text-primary-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50' : 'text-muted-foreground hover:text-foreground hover:bg-background/50'
@@ -25,8 +25,8 @@
</button> </button>
<button <button
type="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="[ :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' activeMainTab === 'key'
? 'bg-primary text-primary-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50' : 'text-muted-foreground hover:text-foreground hover:bg-background/50'
@@ -41,7 +41,10 @@
<!-- 内容区域 --> <!-- 内容区域 -->
<div class="min-h-[420px]"> <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"> <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" /> <Info class="w-3.5 h-3.5 shrink-0" />
@@ -49,18 +52,24 @@
</div> </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" /> <Layers class="w-10 h-10 mb-3 opacity-20" />
<span class="text-sm">暂无提供商</span> <span class="text-sm">暂无提供商</span>
</div> </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 <div
v-for="(provider, index) in sortedProviders" v-for="(provider, index) in sortedProviders"
:key="provider.id" :key="provider.id"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
:class="[ :class="[
'group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200',
draggedProvider === index draggedProvider === index
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]' ? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
: dragOverProvider === index : dragOverProvider === index
@@ -100,7 +109,10 @@
</div> </div>
<!-- Key 优先级 --> <!-- 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"> <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" /> <Info class="w-3.5 h-3.5 shrink-0" />
@@ -108,29 +120,38 @@
</div> </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="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> <span class="text-xs text-muted-foreground">加载中...</span>
</div> </div>
</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" /> <Key class="w-10 h-10 mb-3 opacity-20" />
<span class="text-sm">暂无 API Key</span> <span class="text-sm">暂无 API Key</span>
</div> </div>
<!-- 左右布局格式列表 + Key 列表 --> <!-- 左右布局格式列表 + Key 列表 -->
<div v-else class="flex gap-4"> <div
v-else
class="flex gap-4"
>
<!-- 左侧API 格式列表 --> <!-- 左侧API 格式列表 -->
<div class="w-32 shrink-0 space-y-1"> <div class="w-32 shrink-0 space-y-1">
<button <button
v-for="format in availableFormats" v-for="format in availableFormats"
:key="format" :key="format"
type="button" type="button"
class="w-full px-3 py-2 text-xs font-medium rounded-md text-left transition-all duration-200"
:class="[ :class="[
'w-full px-3 py-2 text-xs font-medium rounded-md text-left transition-all duration-200',
activeFormatTab === format activeFormatTab === format
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
@@ -143,13 +164,20 @@
<!-- 右侧Key 列表 --> <!-- 右侧Key 列表 -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div v-for="format in availableFormats" :key="format" v-show="activeFormatTab === format"> <div
<div v-if="keysByFormat[format]?.length > 0" class="space-y-2 max-h-[380px] overflow-y-auto pr-1"> v-for="format in availableFormats"
v-show="activeFormatTab === format"
:key="format"
>
<div
v-if="keysByFormat[format]?.length > 0"
class="space-y-2 max-h-[380px] overflow-y-auto pr-1"
>
<div <div
v-for="(key, index) in keysByFormat[format]" v-for="(key, index) in keysByFormat[format]"
:key="key.id" :key="key.id"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
:class="[ :class="[
'group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200',
draggedKey[format] === index draggedKey[format] === index
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]' ? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
: dragOverKey[format] === index : dragOverKey[format] === index
@@ -176,15 +204,15 @@
min="1" min="1"
:value="key.priority" :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" 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)" @blur="finishEditKeyPriority(format, key, $event)"
@keydown.enter="($event.target as HTMLInputElement).blur()" @keydown.enter="($event.target as HTMLInputElement).blur()"
@keydown.escape="cancelEditKeyPriority(format)" @keydown.escape="cancelEditKeyPriority(format)"
autofocus >
/>
<div <div
v-else 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" 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="'点击编辑优先级,相同数字为同级(负载均衡)'" title="点击编辑优先级,相同数字为同级(负载均衡)"
@click.stop="startEditKeyPriority(format, key)" @click.stop="startEditKeyPriority(format, key)"
> >
{{ key.priority }} {{ key.priority }}
@@ -213,8 +241,15 @@
</Badge> </Badge>
<!-- 能力标签紧跟名称 --> <!-- 能力标签紧跟名称 -->
<template v-if="key.capabilities?.length"> <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
<span v-if="key.capabilities.length > 2" class="text-[10px] text-muted-foreground">+{{ key.capabilities.length - 2 }}</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> </template>
</div> </div>
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mt-1"> <div class="flex items-center gap-1.5 text-xs text-muted-foreground mt-1">
@@ -226,28 +261,45 @@
<!-- 右侧健康度 + 速率 --> <!-- 右侧健康度 + 速率 -->
<div class="shrink-0 flex items-center gap-3"> <div class="shrink-0 flex items-center gap-3">
<!-- 健康度 --> <!-- 健康度 -->
<div v-if="key.success_rate !== null" class="text-xs text-right"> <div
<div :class="[ v-if="key.success_rate !== null"
'font-medium tabular-nums', class="text-xs text-right"
>
<div
class="font-medium tabular-nums"
:class="[
key.success_rate >= 0.95 ? 'text-green-600' : key.success_rate >= 0.95 ? 'text-green-600' :
key.success_rate >= 0.8 ? 'text-yellow-600' : 'text-red-500' key.success_rate >= 0.8 ? 'text-yellow-600' : 'text-red-500'
]"> ]"
>
{{ (key.success_rate * 100).toFixed(0) }}% {{ (key.success_rate * 100).toFixed(0) }}%
</div> </div>
<div class="text-[10px] text-muted-foreground opacity-70">{{ key.request_count }} reqs</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
v-else
class="text-xs text-muted-foreground/50 text-right"
>
<div>--</div> <div>--</div>
<div class="text-[10px]">无数据</div> <div class="text-[10px]">
无数据
</div>
</div> </div>
<!-- 速率倍数 --> <!-- 速率倍数 -->
<div class="text-sm font-medium tabular-nums text-primary min-w-[40px] text-right">{{ key.rate_multiplier }}x</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> </div>
</div> </div>
<div v-else class="flex flex-col items-center justify-center py-20 text-muted-foreground"> <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" /> <Key class="w-10 h-10 mb-3 opacity-20" />
<span class="text-sm">暂无 {{ format }} 格式的 Key</span> <span class="text-sm">暂无 {{ format }} 格式的 Key</span>
</div> </div>
@@ -264,11 +316,24 @@
当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span> 当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button size="sm" @click="save" :disabled="saving" class="min-w-[72px]"> <Button
<Loader2 v-if="saving" class="w-3.5 h-3.5 mr-1.5 animate-spin" /> 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 ? '保存中' : '保存' }} {{ saving ? '保存中' : '保存' }}
</Button> </Button>
<Button variant="outline" size="sm" @click="close" class="min-w-[72px]"> <Button
variant="outline"
size="sm"
class="min-w-[72px]"
@click="close"
>
取消 取消
</Button> </Button>
</div> </div>

View File

@@ -8,13 +8,19 @@
@click.self="handleBackdropClick" @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"> <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
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></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>
<template v-else-if="provider"> <template v-else-if="provider">
@@ -23,8 +29,13 @@
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="space-y-1 flex-1 min-w-0"> <div class="space-y-1 flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h2 class="text-xl font-bold truncate">{{ provider.display_name }}</h2> <h2 class="text-xl font-bold truncate">
<Badge :variant="provider.is_active ? 'default' : 'secondary'" class="text-xs shrink-0"> {{ provider.display_name }}
</h2>
<Badge
:variant="provider.is_active ? 'default' : 'secondary'"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '已停用' }} {{ provider.is_active ? '活跃' : '已停用' }}
</Badge> </Badge>
</div> </div>
@@ -45,18 +56,28 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <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" /> <Edit class="w-4 h-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@click="$emit('toggle-status', provider)"
:title="provider.is_active ? '点击停用' : '点击启用'" :title="provider.is_active ? '点击停用' : '点击启用'"
@click="$emit('toggle-status', provider)"
> >
<Power class="w-4 h-4" /> <Power class="w-4 h-4" />
</Button> </Button>
<Button variant="ghost" size="icon" @click="handleClose" title="关闭"> <Button
variant="ghost"
size="icon"
title="关闭"
@click="handleClose"
>
<X class="w-4 h-4" /> <X class="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -65,11 +86,19 @@
<div class="space-y-6 p-6"> <div class="space-y-6 p-6">
<!-- 配额使用情况 --> <!-- 配额使用情况 -->
<Card v-if="provider.billing_type === 'monthly_quota' && provider.monthly_quota_usd" class="p-4"> <Card
v-if="provider.billing_type === 'monthly_quota' && provider.monthly_quota_usd"
class="p-4"
>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">订阅配额</h3> <h3 class="text-sm font-semibold">
<Badge variant="secondary" class="text-xs"> 订阅配额
</h3>
<Badge
variant="secondary"
class="text-xs"
>
{{ ((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100).toFixed(1) }}% {{ ((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100).toFixed(1) }}%
</Badge> </Badge>
</div> </div>
@@ -82,13 +111,16 @@
'bg-red-500': (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)}%` }" :style="{ width: `${Math.min((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100, 100)}%` }"
></div> />
</div> </div>
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="font-semibold"> <span class="font-semibold">
${{ (provider.monthly_used_usd || 0).toFixed(2) }} / ${{ provider.monthly_quota_usd.toFixed(2) }} ${{ (provider.monthly_used_usd || 0).toFixed(2) }} / ${{ provider.monthly_quota_usd.toFixed(2) }}
</span> </span>
<span v-if="provider.quota_reset_day" class="text-muted-foreground"> <span
v-if="provider.quota_reset_day"
class="text-muted-foreground"
>
每月 {{ provider.quota_reset_day }} 号重置 每月 {{ provider.quota_reset_day }} 号重置
</span> </span>
</div> </div>
@@ -102,7 +134,12 @@
<h3 class="text-sm font-semibold flex items-center gap-2"> <h3 class="text-sm font-semibold flex items-center gap-2">
<span>端点与密钥管理</span> <span>端点与密钥管理</span>
</h3> </h3>
<Button @click="showAddEndpointDialog" variant="outline" size="sm" class="h-8"> <Button
variant="outline"
size="sm"
class="h-8"
@click="showAddEndpointDialog"
>
<Plus class="w-3.5 h-3.5 mr-1.5" /> <Plus class="w-3.5 h-3.5 mr-1.5" />
添加端点 添加端点
</Button> </Button>
@@ -110,7 +147,10 @@
</div> </div>
<!-- 端点列表 --> <!-- 端点列表 -->
<div v-if="endpoints.length > 0" class="divide-y divide-border/40"> <div
v-if="endpoints.length > 0"
class="divide-y divide-border/40"
>
<div <div
v-for="endpoint in endpoints" v-for="endpoint in endpoints"
:key="endpoint.id" :key="endpoint.id"
@@ -141,10 +181,16 @@
<Key class="w-3 h-3" /> <Key class="w-3 h-3" />
{{ endpoint.keys?.filter((k: EndpointAPIKey) => k.is_active).length || 0 }} {{ endpoint.keys?.filter((k: EndpointAPIKey) => k.is_active).length || 0 }}
</span> </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 }}次重试 {{ endpoint.max_retries }}次重试
</span> </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 {{ endpoint.timeout }}s
</span> </span>
</div> </div>
@@ -156,61 +202,70 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-5 w-5 shrink-0" class="h-5 w-5 shrink-0"
@click.stop="copyToClipboard(endpoint.base_url)"
title="复制 Base URL" title="复制 Base URL"
@click.stop="copyToClipboard(endpoint.base_url)"
> >
<Copy class="w-3 h-3" /> <Copy class="w-3 h-3" />
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center gap-1" @click.stop> <div
class="flex items-center gap-1"
@click.stop
>
<Button <Button
v-if="hasUnhealthyKeys(endpoint)" v-if="hasUnhealthyKeys(endpoint)"
@click="handleRecoverAllKeys(endpoint)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8 text-green-600" class="h-8 w-8 text-green-600"
title="恢复所有密钥健康状态" title="恢复所有密钥健康状态"
:disabled="recoveringEndpointId === endpoint.id" :disabled="recoveringEndpointId === endpoint.id"
@click="handleRecoverAllKeys(endpoint)"
> >
<Loader2 v-if="recoveringEndpointId === endpoint.id" class="w-3.5 h-3.5 animate-spin" /> <Loader2
<RefreshCw v-else class="w-3.5 h-3.5" /> 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>
<Button <Button
@click="handleAddKey(endpoint)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
title="添加密钥" title="添加密钥"
@click="handleAddKey(endpoint)"
> >
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
</Button> </Button>
<Button <Button
@click="handleEditEndpoint(endpoint)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
title="编辑端点" title="编辑端点"
@click="handleEditEndpoint(endpoint)"
> >
<Edit class="w-3.5 h-3.5" /> <Edit class="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
@click="toggleEndpointActive(endpoint)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
:disabled="togglingEndpointId === endpoint.id" :disabled="togglingEndpointId === endpoint.id"
:title="endpoint.is_active ? '点击停用' : '点击启用'" :title="endpoint.is_active ? '点击停用' : '点击启用'"
@click="toggleEndpointActive(endpoint)"
> >
<Power class="w-3.5 h-3.5" /> <Power class="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
@click="handleDeleteEndpoint(endpoint)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
title="删除端点" title="删除端点"
@click="handleDeleteEndpoint(endpoint)"
> >
<Trash2 class="w-3.5 h-3.5" /> <Trash2 class="w-3.5 h-3.5" />
</Button> </Button>
@@ -225,7 +280,10 @@
> >
<div class="space-y-3 pt-3"> <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"> <div v-if="endpoint.custom_path">
<span class="text-muted-foreground">自定义路径:</span> <span class="text-muted-foreground">自定义路径:</span>
<span class="ml-1 font-mono">{{ endpoint.custom_path }}</span> <span class="ml-1 font-mono">{{ endpoint.custom_path }}</span>
@@ -238,16 +296,14 @@
<!-- 密钥列表 --> <!-- 密钥列表 -->
<div class="space-y-2"> <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 <div
v-for="key in endpoint.keys" v-for="key in endpoint.keys"
:key="key.id" :key="key.id"
draggable="true" 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="p-3 bg-background rounded-md border transition-all duration-150 group/key"
:class="{ :class="{
'border-border/40 hover:border-border/80': dragState.targetKeyId !== key.id, 'border-border/40 hover:border-border/80': dragState.targetKeyId !== key.id,
@@ -255,6 +311,11 @@
'opacity-50': dragState.draggedKeyId === key.id, 'opacity-50': dragState.draggedKeyId === key.id,
'cursor-grabbing': dragState.isDragging '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"> <div class="flex items-center justify-between mb-2">
@@ -276,7 +337,9 @@
{{ key.is_active ? '活跃' : '禁用' }} {{ key.is_active ? '活跃' : '禁用' }}
</Badge> </Badge>
</div> </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>
<div class="flex items-center gap-1.5 ml-auto shrink-0"> <div class="flex items-center gap-1.5 ml-auto shrink-0">
<div <div
@@ -309,48 +372,48 @@
<div class="flex items-center gap-1 ml-2"> <div class="flex items-center gap-1 ml-2">
<Button <Button
v-if="key.circuit_breaker_open || (key.health_score !== undefined && key.health_score < 0.5)" v-if="key.circuit_breaker_open || (key.health_score !== undefined && key.health_score < 0.5)"
@click="handleRecoverKey(key)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7 text-green-600" class="h-7 w-7 text-green-600"
title="刷新健康状态" title="刷新健康状态"
@click="handleRecoverKey(key)"
> >
<RefreshCw class="w-3 h-3" /> <RefreshCw class="w-3 h-3" />
</Button> </Button>
<Button <Button
@click="handleConfigKeyModels(key)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="配置允许的模型" title="配置允许的模型"
@click="handleConfigKeyModels(key)"
> >
<Layers class="w-3 h-3" /> <Layers class="w-3 h-3" />
</Button> </Button>
<Button <Button
@click="handleEditKey(endpoint, key)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="编辑密钥" title="编辑密钥"
@click="handleEditKey(endpoint, key)"
> >
<Edit class="w-3 h-3" /> <Edit class="w-3 h-3" />
</Button> </Button>
<Button <Button
@click="toggleKeyActive(key)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
:disabled="togglingKeyId === key.id" :disabled="togglingKeyId === key.id"
:title="key.is_active ? '点击停用' : '点击启用'" :title="key.is_active ? '点击停用' : '点击启用'"
@click="toggleKeyActive(key)"
> >
<Power class="w-3 h-3" /> <Power class="w-3 h-3" />
</Button> </Button>
<Button <Button
@click="handleDeleteKey(key)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="删除密钥" title="删除密钥"
@click="handleDeleteKey(key)"
> >
<Trash2 class="w-3 h-3" /> <Trash2 class="w-3 h-3" />
</Button> </Button>
@@ -371,42 +434,67 @@
P {{ key.internal_priority }} P {{ key.internal_priority }}
</span> </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> <span class="text-muted-foreground">P</span>
<input <input
type="number" ref="priorityInput"
v-model.number="editingPriorityValue" 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" class="w-12 h-5 px-1 text-[11px] border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary"
min="0" min="0"
@keyup.enter="savePriority(key, endpoint)" @keyup.enter="savePriority(key, endpoint)"
@keyup.escape="cancelEditPriority" @keyup.escape="cancelEditPriority"
@blur="savePriority(key, endpoint)" @blur="savePriority(key, endpoint)"
ref="priorityInput" >
/>
</span> </span>
<span class="text-muted-foreground" title="成本倍率,实际成本 = 模型价格 × 倍率"> <span
class="text-muted-foreground"
title="成本倍率,实际成本 = 模型价格 × 倍率"
>
{{ key.rate_multiplier }}x {{ key.rate_multiplier }}x
</span> </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 }}) {{ (key.success_rate * 100).toFixed(1) }}% ({{ key.success_count }}/{{ key.request_count }})
</span> </span>
</div> </div>
<!-- 右侧动态信息 --> <!-- 右侧动态信息 -->
<div class="flex items-center gap-2 ml-auto"> <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) }}探测 {{ formatProbeTime(key.next_probe_at) }}探测
</span> </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 {{ key.rate_limit }}rpm
</span> </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 }} {{ key.is_adaptive ? '自适应' : '固定' }}并发: {{ key.learned_max_concurrent || key.max_concurrent || 3 }}
</span> </span>
</div> </div>
</div> </div>
</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>
</div> </div>
@@ -416,10 +504,17 @@
</div> </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" /> <Server class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">暂无端点配置</p> <p class="text-sm">
<p class="text-xs mt-1">点击上方"添加端点"按钮创建第一个端点</p> 暂无端点配置
</p>
<p class="text-xs mt-1">
点击上方"添加端点"按钮创建第一个端点
</p>
</div> </div>
</Card> </Card>

View File

@@ -7,13 +7,21 @@
size="2xl" size="2xl"
@update:model-value="handleDialogUpdate" @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"> <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> <Label for="name">提供商标识 *</Label>
<Input <Input
id="name" id="name"
@@ -21,7 +29,9 @@
placeholder="例如: openai-primary" placeholder="例如: openai-primary"
required required
/> />
<p class="text-xs text-muted-foreground">唯一ID创建后不可修改</p> <p class="text-xs text-muted-foreground">
唯一ID创建后不可修改
</p>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
@@ -58,18 +68,29 @@
<!-- 计费与限流 --> <!-- 计费与限流 -->
<div class="space-y-4"> <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="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>计费类型</Label> <Label>计费类型</Label>
<Select v-model="form.billing_type" v-model:open="billingTypeSelectOpen"> <Select
v-model="form.billing_type"
v-model:open="billingTypeSelectOpen"
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="monthly_quota">月卡额度</SelectItem> <SelectItem value="monthly_quota">
<SelectItem value="pay_as_you_go">按量付费</SelectItem> 月卡额度
<SelectItem value="free_tier">免费套餐</SelectItem> </SelectItem>
<SelectItem value="pay_as_you_go">
按量付费
</SelectItem>
<SelectItem value="free_tier">
免费套餐
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -86,7 +107,10 @@
</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
v-if="form.billing_type === 'monthly_quota'"
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
>
<div class="space-y-2"> <div class="space-y-2">
<Label class="text-xs">周期额度 (USD)</Label> <Label class="text-xs">周期额度 (USD)</Label>
<Input <Input
@@ -114,13 +138,25 @@
周期开始时间 周期开始时间
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
</Label> </Label>
<Input v-model="form.quota_last_reset_at" type="datetime-local" class="h-9" /> <Input
<p class="text-xs text-muted-foreground">系统会自动统计从该时间点开始的使用量</p> v-model="form.quota_last_reset_at"
type="datetime-local"
class="h-9"
/>
<p class="text-xs text-muted-foreground">
系统会自动统计从该时间点开始的使用量
</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label class="text-xs">过期时间</Label> <Label class="text-xs">过期时间</Label>
<Input v-model="form.quota_expires_at" type="datetime-local" class="h-9" /> <Input
<p class="text-xs text-muted-foreground">留空表示永久有效</p> v-model="form.quota_expires_at"
type="datetime-local"
class="h-9"
/>
<p class="text-xs text-muted-foreground">
留空表示永久有效
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -130,14 +166,14 @@
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@click="handleCancel"
:disabled="loading" :disabled="loading"
@click="handleCancel"
> >
取消 取消
</Button> </Button>
<Button <Button
@click="handleSubmit"
:disabled="loading || !form.display_name || (!isEditMode && !form.name)" :disabled="loading || !form.display_name || (!isEditMode && !form.name)"
@click="handleSubmit"
> >
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存' : '创建') }} {{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存' : '创建') }}
</Button> </Button>
@@ -147,16 +183,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Dialog } from '@/components/ui' import {
import Button from '@/components/ui/button.vue' Dialog,
import Input from '@/components/ui/input.vue' Button,
import Textarea from '@/components/ui/textarea.vue' Input,
import Label from '@/components/ui/label.vue' Textarea,
import Select from '@/components/ui/select.vue' Label,
import SelectTrigger from '@/components/ui/select-trigger.vue' Select,
import SelectValue from '@/components/ui/select-value.vue' SelectTrigger,
import SelectContent from '@/components/ui/select-content.vue' SelectValue,
import SelectItem from '@/components/ui/select-item.vue' SelectContent,
SelectItem,
} from '@/components/ui'
import { Server, SquarePen } from 'lucide-vue-next' import { Server, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog' import { useFormDialog } from '@/composables/useFormDialog'

View File

@@ -1,15 +1,21 @@
<template> <template>
<Dialog <Dialog
:model-value="open" :model-value="open"
@update:model-value="handleClose"
:title="isEditing ? '编辑模型配置' : '添加模型'" :title="isEditing ? '编辑模型配置' : '添加模型'"
:description="isEditing ? '修改模型价格和能力配置' : '为此 Provider 添加模型实现'" :description="isEditing ? '修改模型价格和能力配置' : '为此 Provider 添加模型实现'"
:icon="isEditing ? SquarePen : Layers" :icon="isEditing ? SquarePen : Layers"
size="xl" size="xl"
@update:model-value="handleClose"
>
<form
class="space-y-4"
@submit.prevent="handleSubmit"
> >
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- 添加模式选择全局模型 --> <!-- 添加模式选择全局模型 -->
<div v-if="!isEditing" class="space-y-2"> <div
v-if="!isEditing"
class="space-y-2"
>
<Label for="global-model">选择模型 *</Label> <Label for="global-model">选择模型 *</Label>
<Select <Select
v-model:open="globalModelSelectOpen" v-model:open="globalModelSelectOpen"
@@ -30,25 +36,41 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </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 所有全局模型已添加到此 Provider
</p> </p>
</div> </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 class="flex items-start justify-between">
<div> <div>
<p class="font-semibold text-lg">{{ editingModel?.global_model_display_name || editingModel?.provider_model_name }}</p> <p class="font-semibold text-lg">
<p class="text-sm text-muted-foreground font-mono">{{ editingModel?.provider_model_name }}</p> {{ 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>
</div> </div>
<!-- 价格配置 --> <!-- 价格配置 -->
<div class="space-y-4"> <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">
<TieredPricingEditor ref="tieredPricingEditorRef" v-model="tieredPricing" :show-cache1h="showCache1h" /> 价格配置
</h4>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="showCache1h"
/>
<!-- 按次计费 --> <!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t"> <div class="flex items-center gap-3 pt-2 border-t">
@@ -68,70 +90,80 @@
<!-- 能力配置 --> <!-- 能力配置 -->
<div class="space-y-4"> <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"> <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"> <label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input <input
type="checkbox"
v-model="form.supports_streaming" v-model="form.supports_streaming"
type="checkbox"
:indeterminate="form.supports_streaming === undefined" :indeterminate="form.supports_streaming === undefined"
class="rounded" class="rounded"
/> >
<Zap class="w-4 h-4 text-muted-foreground shrink-0" /> <Zap class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">流式输出</span> <span class="text-sm font-medium">流式输出</span>
</label> </label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50"> <label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input <input
type="checkbox"
v-model="form.supports_image_generation" v-model="form.supports_image_generation"
type="checkbox"
:indeterminate="form.supports_image_generation === undefined" :indeterminate="form.supports_image_generation === undefined"
class="rounded" class="rounded"
/> >
<Image class="w-4 h-4 text-muted-foreground shrink-0" /> <Image class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">图像生成</span> <span class="text-sm font-medium">图像生成</span>
</label> </label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50"> <label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input <input
type="checkbox"
v-model="form.supports_vision" v-model="form.supports_vision"
type="checkbox"
:indeterminate="form.supports_vision === undefined" :indeterminate="form.supports_vision === undefined"
class="rounded" class="rounded"
/> >
<Eye class="w-4 h-4 text-muted-foreground shrink-0" /> <Eye class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">视觉理解</span> <span class="text-sm font-medium">视觉理解</span>
</label> </label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50"> <label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input <input
type="checkbox"
v-model="form.supports_function_calling" v-model="form.supports_function_calling"
type="checkbox"
:indeterminate="form.supports_function_calling === undefined" :indeterminate="form.supports_function_calling === undefined"
class="rounded" class="rounded"
/> >
<Wrench class="w-4 h-4 text-muted-foreground shrink-0" /> <Wrench class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">工具调用</span> <span class="text-sm font-medium">工具调用</span>
</label> </label>
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50"> <label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
<input <input
type="checkbox"
v-model="form.supports_extended_thinking" v-model="form.supports_extended_thinking"
type="checkbox"
:indeterminate="form.supports_extended_thinking === undefined" :indeterminate="form.supports_extended_thinking === undefined"
class="rounded" class="rounded"
/> >
<Brain class="w-4 h-4 text-muted-foreground shrink-0" /> <Brain class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="text-sm font-medium">深度思考</span> <span class="text-sm font-medium">深度思考</span>
</label> </label>
</div> </div>
</div> </div>
</form> </form>
<template #footer> <template #footer>
<Button variant="outline" @click="handleClose(false)"> <Button
variant="outline"
@click="handleClose(false)"
>
取消 取消
</Button> </Button>
<Button @click="handleSubmit" :disabled="submitting || (!isEditing && !form.global_model_id)"> <Button
<Loader2 v-if="submitting" class="w-4 h-4 mr-2 animate-spin" /> :disabled="submitting || (!isEditing && !form.global_model_id)"
@click="handleSubmit"
>
<Loader2
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
{{ isEditing ? '保存' : '添加' }} {{ isEditing ? '保存' : '添加' }}
</Button> </Button>
</template> </template>
@@ -141,10 +173,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { Eye, Wrench, Brain, Zap, Loader2, Image, Layers, SquarePen } from 'lucide-vue-next' import { Eye, Wrench, Brain, Zap, Loader2, Image, Layers, SquarePen } from 'lucide-vue-next'
import { Dialog, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui' import {
import Button from '@/components/ui/button.vue' Dialog,
import Input from '@/components/ui/input.vue' Button,
import Label from '@/components/ui/label.vue' Input,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { parseNumberInput } from '@/utils/form' import { parseNumberInput } from '@/utils/form'
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models' import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'

View File

@@ -10,10 +10,10 @@
</div> </div>
<Button <Button
v-if="!hideAddButton" v-if="!hideAddButton"
@click="openCreateDialog"
variant="outline" variant="outline"
size="sm" size="sm"
class="h-8" class="h-8"
@click="openCreateDialog"
> >
<Plus class="w-3.5 h-3.5 mr-1.5" /> <Plus class="w-3.5 h-3.5 mr-1.5" />
创建别名/映射 创建别名/映射
@@ -22,19 +22,36 @@
</div> </div>
<!-- 加载状态 --> <!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12"> <div
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></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"> <div
v-else-if="mappings.length > 0"
class="overflow-x-auto"
>
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground"> <thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
<tr> <tr>
<th class="text-left px-4 py-3 font-semibold">名称</th> <th class="text-left px-4 py-3 font-semibold">
<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>
<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 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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -50,19 +67,25 @@
class="w-2 h-2 rounded-full shrink-0" class="w-2 h-2 rounded-full shrink-0"
:class="mapping.is_active ? 'bg-green-500' : 'bg-gray-300'" :class="mapping.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="mapping.is_active ? '活跃' : '停用'" :title="mapping.is_active ? '活跃' : '停用'"
></span> />
<span class="font-mono">{{ mapping.alias }}</span> <span class="font-mono">{{ mapping.alias }}</span>
</div> </div>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<Badge variant="secondary" class="text-xs"> <Badge
variant="secondary"
class="text-xs"
>
{{ mapping.mapping_type === 'mapping' ? '映射' : '别名' }} {{ mapping.mapping_type === 'mapping' ? '映射' : '别名' }}
</Badge> </Badge>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{{ mapping.global_model_display_name || mapping.global_model_name }} {{ mapping.global_model_display_name || mapping.global_model_name }}
</td> </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"> <div class="flex justify-center gap-1.5">
<Button <Button
variant="ghost" variant="ghost"
@@ -78,8 +101,8 @@
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
:disabled="togglingId === mapping.id" :disabled="togglingId === mapping.id"
@click="toggleActive(mapping)"
:title="mapping.is_active ? '点击停用' : '点击启用'" :title="mapping.is_active ? '点击停用' : '点击启用'"
@click="toggleActive(mapping)"
> >
<Power class="w-3.5 h-3.5" /> <Power class="w-3.5 h-3.5" />
</Button> </Button>
@@ -100,10 +123,17 @@
</div> </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" /> <ArrowLeftRight class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">暂无特定别名/映射</p> <p class="text-sm">
<p class="text-xs mt-1">点击上方按钮添加</p> 暂无特定别名/映射
</p>
<p class="text-xs mt-1">
点击上方按钮添加
</p>
</div> </div>
</Card> </Card>

View File

@@ -6,7 +6,12 @@
<h3 class="text-sm font-semibold flex items-center gap-2"> <h3 class="text-sm font-semibold flex items-center gap-2">
模型列表 模型列表
</h3> </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" /> <Layers class="w-3.5 h-3.5 mr-1.5" />
关联模型 关联模型
</Button> </Button>
@@ -14,19 +19,33 @@
</div> </div>
<!-- 加载状态 --> <!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12"> <div
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></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"> <div
v-else-if="models.length > 0"
class="overflow-hidden"
>
<table class="w-full text-sm table-fixed"> <table class="w-full text-sm table-fixed">
<thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground"> <thead class="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
<tr> <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-[40%]">
<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>
<th class="text-center px-4 py-3 font-semibold w-[20%]">操作</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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -42,7 +61,7 @@
class="w-2 h-2 rounded-full shrink-0" class="w-2 h-2 rounded-full shrink-0"
:class="getStatusIndicatorClass(model)" :class="getStatusIndicatorClass(model)"
:title="getStatusTitle(model)" :title="getStatusTitle(model)"
></div> />
<!-- 模型信息 --> <!-- 模型信息 -->
<div class="text-left flex-1 min-w-0"> <div class="text-left flex-1 min-w-0">
<span class="font-semibold text-sm"> <span class="font-semibold text-sm">
@@ -51,9 +70,9 @@
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1"> <div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<span class="font-mono truncate">{{ model.provider_model_name }}</span> <span class="font-mono truncate">{{ model.provider_model_name }}</span>
<button <button
@click.stop="copyModelId(model.provider_model_name)"
class="p-0.5 hover:bg-muted rounded transition-colors shrink-0" class="p-0.5 hover:bg-muted rounded transition-colors shrink-0"
title="复制模型 ID" title="复制模型 ID"
@click.stop="copyModelId(model.provider_model_name)"
> >
<Copy class="w-3 h-3" /> <Copy class="w-3 h-3" />
</button> </button>
@@ -62,17 +81,46 @@
</div> </div>
</td> </td>
<td class="align-top px-4 py-3"> <td class="align-top px-4 py-3">
<div v-if="hasAnyCapability(model)" class="grid grid-cols-3 gap-1 w-fit"> <div
<Zap v-if="model.effective_supports_streaming ?? model.supports_streaming" class="w-4 h-4 text-muted-foreground" title="流式输出" /> v-if="hasAnyCapability(model)"
<Image v-if="model.effective_supports_image_generation ?? model.supports_image_generation" class="w-4 h-4 text-muted-foreground" title="图像生成" /> class="grid grid-cols-3 gap-1 w-fit"
<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="工具调用" /> <Zap
<Brain v-if="model.effective_supports_extended_thinking ?? model.supports_extended_thinking" class="w-4 h-4 text-muted-foreground" title="深度思考" /> 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> </div>
<span v-else class="text-xs text-muted-foreground"></span> <span
v-else
class="text-xs text-muted-foreground"
></span>
</td> </td>
<td class="align-top px-4 py-3 text-xs whitespace-nowrap"> <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 计费 --> <!-- Token 计费 -->
<template v-if="hasTokenPricing(model)"> <template v-if="hasTokenPricing(model)">
<span class="text-muted-foreground text-right">输入/输出:</span> <span class="text-muted-foreground text-right">输入/输出:</span>
@@ -112,8 +160,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@click="editModel(model)"
title="编辑" title="编辑"
@click="editModel(model)"
> >
<Edit class="w-3.5 h-3.5" /> <Edit class="w-3.5 h-3.5" />
</Button> </Button>
@@ -122,8 +170,8 @@
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
:disabled="togglingModelId === model.id" :disabled="togglingModelId === model.id"
@click="toggleModelActive(model)"
:title="model.is_active ? '点击停用' : '点击启用'" :title="model.is_active ? '点击停用' : '点击启用'"
@click="toggleModelActive(model)"
> >
<Power class="w-3.5 h-3.5" /> <Power class="w-3.5 h-3.5" />
</Button> </Button>
@@ -131,8 +179,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive" class="h-8 w-8 text-destructive hover:text-destructive"
@click="deleteModel(model)"
title="删除" title="删除"
@click="deleteModel(model)"
> >
<Trash2 class="w-3.5 h-3.5" /> <Trash2 class="w-3.5 h-3.5" />
</Button> </Button>
@@ -144,10 +192,17 @@
</div> </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" /> <Box class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">暂无模型</p> <p class="text-sm">
<p class="text-xs mt-1">请前往"模型目录"页面添加模型</p> 暂无模型
</p>
<p class="text-xs mt-1">
请前往"模型目录"页面添加模型
</p>
</div> </div>
</Card> </Card>
</template> </template>

View File

@@ -1,8 +1,13 @@
<template> <template>
<Card class="p-4 !overflow-visible"> <Card class="p-4 !overflow-visible">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-sm font-semibold">{{ title }}</p> <p class="text-sm font-semibold">
<div v-if="hasData" class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0"> {{ title }}
</p>
<div
v-if="hasData"
class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0"
>
<span class="flex-shrink-0"></span> <span class="flex-shrink-0"></span>
<div <div
v-for="(level, index) in legendLevels" v-for="(level, index) in legendLevels"
@@ -18,7 +23,10 @@
:data="data" :data="data"
:show-header="false" :show-header="false"
/> />
<div v-else class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"> <div
v-else
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
>
暂无活跃数据 暂无活跃数据
</div> </div>
</Card> </Card>

View File

@@ -1,25 +1,38 @@
<template> <template>
<div class="minimal-request-timeline"> <div class="minimal-request-timeline">
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="py-4"> <div
v-if="loading"
class="py-4"
>
<Skeleton class="h-32 w-full" /> <Skeleton class="h-32 w-full" />
</div> </div>
<!-- Error State --> <!-- Error State -->
<Card v-else-if="error" class="border-red-200 dark:border-red-800"> <Card
v-else-if="error"
class="border-red-200 dark:border-red-800"
>
<div class="p-4"> <div class="p-4">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p> <p class="text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div> </div>
</Card> </Card>
<!-- Timeline Content --> <!-- Timeline Content -->
<div v-else-if="trace && trace.candidates.length > 0" class="space-y-0"> <div
v-else-if="trace && trace.candidates.length > 0"
class="space-y-0"
>
<Card> <Card>
<div class="p-6"> <div class="p-6">
<!-- 概览信息 --> <!-- 概览信息 -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h4 class="text-sm font-semibold">请求链路追踪</h4> <h4 class="text-sm font-semibold">
请求链路追踪
</h4>
<Badge :variant="getFinalStatusBadgeVariant(computedFinalStatus)"> <Badge :variant="getFinalStatusBadgeVariant(computedFinalStatus)">
{{ getFinalStatusLabel(computedFinalStatus) }} {{ getFinalStatusLabel(computedFinalStatus) }}
</Badge> </Badge>
@@ -46,7 +59,9 @@
<!-- 节点容器 --> <!-- 节点容器 -->
<div class="node-container"> <div class="node-container">
<!-- 节点名称在节点上方 --> <!-- 节点名称在节点上方 -->
<div class="node-label">{{ group.providerName }}</div> <div class="node-label">
{{ group.providerName }}
</div>
<!-- 主节点代表首次请求 --> <!-- 主节点代表首次请求 -->
<div <div
@@ -56,10 +71,13 @@
{ 'is-first-selected': isGroupSelected(group) && selectedAttemptIndex === 0 } { 'is-first-selected': isGroupSelected(group) && selectedAttemptIndex === 0 }
]" ]"
@click.stop="selectFirstAttempt(group)" @click.stop="selectFirstAttempt(group)"
></div> />
<!-- 子节点同提供商的其他尝试不包含首次 --> <!-- 子节点同提供商的其他尝试不包含首次 -->
<div v-if="group.retryCount > 0 && isGroupSelected(group)" class="sub-dots"> <div
v-if="group.retryCount > 0 && isGroupSelected(group)"
class="sub-dots"
>
<button <button
v-for="(attempt, idx) in group.allAttempts.slice(1)" v-for="(attempt, idx) in group.allAttempts.slice(1)"
:key="attempt.id" :key="attempt.id"
@@ -68,23 +86,32 @@
getStatusColorClass(attempt.status), getStatusColorClass(attempt.status),
{ active: selectedAttemptIndex === idx + 1 } { active: selectedAttemptIndex === idx + 1 }
]" ]"
@click.stop="selectedAttemptIndex = idx + 1"
:title="attempt.key_name || `Key ${idx + 2}`" :title="attempt.key_name || `Key ${idx + 2}`"
></button> @click.stop="selectedAttemptIndex = idx + 1"
/>
</div> </div>
</div> </div>
<!-- 连接线 --> <!-- 连接线 -->
<div class="node-line" v-if="groupIndex < groupedTimeline.length - 1"></div> <div
v-if="groupIndex < groupedTimeline.length - 1"
class="node-line"
/>
</div> </div>
</div> </div>
<!-- 选中详情面板 --> <!-- 选中详情面板 -->
<Transition name="slide-up"> <Transition name="slide-up">
<div v-if="selectedGroup && currentAttempt" class="detail-panel"> <div
v-if="selectedGroup && currentAttempt"
class="detail-panel"
>
<div class="panel-header"> <div class="panel-header">
<div class="panel-title"> <div class="panel-title">
<span class="title-dot" :class="getStatusColorClass(currentAttempt.status)"></span> <span
class="title-dot"
:class="getStatusColorClass(currentAttempt.status)"
/>
<span class="title-text">{{ selectedGroup.providerName }}</span> <span class="title-text">{{ selectedGroup.providerName }}</span>
<a <a
v-if="currentAttempt.provider_website" v-if="currentAttempt.provider_website"
@@ -96,7 +123,10 @@
> >
<ExternalLink class="w-3 h-3" /> <ExternalLink class="w-3 h-3" />
</a> </a>
<span class="status-tag" :class="getStatusColorClass(currentAttempt.status)"> <span
class="status-tag"
:class="getStatusColorClass(currentAttempt.status)"
>
{{ currentAttempt.status_code || getStatusLabel(currentAttempt.status) }} {{ currentAttempt.status_code || getStatusLabel(currentAttempt.status) }}
</span> </span>
<!-- Key 标识 --> <!-- Key 标识 -->
@@ -126,31 +156,45 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
<!-- 核心信息网格 --> <!-- 核心信息网格 -->
<div class="info-grid"> <div class="info-grid">
<div class="info-item" v-if="currentAttempt.started_at"> <div
v-if="currentAttempt.started_at"
class="info-item"
>
<span class="info-label">时间范围</span> <span class="info-label">时间范围</span>
<span class="info-value mono time-range-value"> <span class="info-value mono time-range-value">
{{ formatTime(currentAttempt.started_at) }} {{ formatTime(currentAttempt.started_at) }}
<span class="time-arrow-container"> <span class="time-arrow-container">
<span class="time-duration" v-if="currentAttempt.finished_at">+{{ formatDuration(currentAttempt.started_at, currentAttempt.finished_at) }}</span> <span
v-if="currentAttempt.finished_at"
class="time-duration"
>+{{ formatDuration(currentAttempt.started_at, currentAttempt.finished_at) }}</span>
<span class="time-arrow"></span> <span class="time-arrow"></span>
</span> </span>
{{ currentAttempt.finished_at ? formatTime(currentAttempt.finished_at) : '进行中' }} {{ currentAttempt.finished_at ? formatTime(currentAttempt.finished_at) : '进行中' }}
</span> </span>
</div> </div>
<div class="info-item" v-if="currentAttempt.key_name || currentAttempt.key_id"> <div
v-if="currentAttempt.key_name || currentAttempt.key_id"
class="info-item"
>
<span class="info-label">密钥</span> <span class="info-label">密钥</span>
<span class="info-value"> <span class="info-value">
<span class="key-name">{{ currentAttempt.key_name || '未知' }}</span> <span class="key-name">{{ currentAttempt.key_name || '未知' }}</span>
<template v-if="currentAttempt.key_preview"> <template v-if="currentAttempt.key_preview">
<Separator orientation="vertical" class="h-3 mx-2" /> <Separator
orientation="vertical"
class="h-3 mx-2"
/>
<code class="key-preview">{{ currentAttempt.key_preview }}</code> <code class="key-preview">{{ currentAttempt.key_preview }}</code>
</template> </template>
</span> </span>
</div> </div>
<div class="info-item" v-if="mergedCapabilities.length > 0"> <div
v-if="mergedCapabilities.length > 0"
class="info-item"
>
<span class="info-label">能力</span> <span class="info-label">能力</span>
<span class="info-value"> <span class="info-value">
<span class="capability-tags"> <span class="capability-tags">
@@ -166,7 +210,10 @@
</div> </div>
<!-- 用量与费用仅成功节点显示 --> <!-- 用量与费用仅成功节点显示 -->
<div v-if="currentAttempt.status === 'success' && usageData" class="usage-section"> <div
v-if="currentAttempt.status === 'success' && usageData"
class="usage-section"
>
<div class="usage-grid"> <div class="usage-grid">
<!-- 输入 输出 --> <!-- 输入 输出 -->
<div class="usage-row"> <div class="usage-row">
@@ -175,7 +222,7 @@
<span class="usage-tokens">{{ formatNumber(usageData.tokens.input) }}</span> <span class="usage-tokens">{{ formatNumber(usageData.tokens.input) }}</span>
<span class="usage-cost">${{ usageData.cost.input.toFixed(6) }}</span> <span class="usage-cost">${{ usageData.cost.input.toFixed(6) }}</span>
</div> </div>
<div class="usage-divider"></div> <div class="usage-divider" />
<div class="usage-item"> <div class="usage-item">
<span class="usage-label">输出</span> <span class="usage-label">输出</span>
<span class="usage-tokens">{{ formatNumber(usageData.tokens.output) }}</span> <span class="usage-tokens">{{ formatNumber(usageData.tokens.output) }}</span>
@@ -183,13 +230,16 @@
</div> </div>
</div> </div>
<!-- 缓存创建 缓存读取仅在有缓存数据时显示 --> <!-- 缓存创建 缓存读取仅在有缓存数据时显示 -->
<div v-if="usageData.tokens.cache_creation || usageData.tokens.cache_read" class="usage-row"> <div
v-if="usageData.tokens.cache_creation || usageData.tokens.cache_read"
class="usage-row"
>
<div class="usage-item"> <div class="usage-item">
<span class="usage-label">缓存创建</span> <span class="usage-label">缓存创建</span>
<span class="usage-tokens">{{ formatNumber(usageData.tokens.cache_creation || 0) }}</span> <span class="usage-tokens">{{ formatNumber(usageData.tokens.cache_creation || 0) }}</span>
<span class="usage-cost">${{ (usageData.cost.cache_creation || 0).toFixed(6) }}</span> <span class="usage-cost">${{ (usageData.cost.cache_creation || 0).toFixed(6) }}</span>
</div> </div>
<div class="usage-divider"></div> <div class="usage-divider" />
<div class="usage-item"> <div class="usage-item">
<span class="usage-label">缓存读取</span> <span class="usage-label">缓存读取</span>
<span class="usage-tokens">{{ formatNumber(usageData.tokens.cache_read || 0) }}</span> <span class="usage-tokens">{{ formatNumber(usageData.tokens.cache_read || 0) }}</span>
@@ -200,20 +250,35 @@
</div> </div>
<!-- 跳过原因 --> <!-- 跳过原因 -->
<div v-if="currentAttempt.skip_reason" class="skip-reason"> <div
v-if="currentAttempt.skip_reason"
class="skip-reason"
>
<span class="reason-label">跳过原因</span> <span class="reason-label">跳过原因</span>
<span class="reason-value">{{ currentAttempt.skip_reason }}</span> <span class="reason-value">{{ currentAttempt.skip_reason }}</span>
</div> </div>
<!-- 错误信息 --> <!-- 错误信息 -->
<div v-if="currentAttempt.status === 'failed' && (currentAttempt.error_message || currentAttempt.error_type)" class="error-block"> <div
<div class="error-type">{{ currentAttempt.error_type || '错误' }}</div> v-if="currentAttempt.status === 'failed' && (currentAttempt.error_message || currentAttempt.error_type)"
<div class="error-msg">{{ currentAttempt.error_message || '未知错误' }}</div> class="error-block"
>
<div class="error-type">
{{ currentAttempt.error_type || '错误' }}
</div>
<div class="error-msg">
{{ currentAttempt.error_message || '未知错误' }}
</div>
</div> </div>
<!-- 额外数据 --> <!-- 额外数据 -->
<details v-if="currentAttempt.extra_data && Object.keys(currentAttempt.extra_data).length > 0" class="extra-block"> <details
<summary class="extra-toggle">额外信息</summary> v-if="currentAttempt.extra_data && Object.keys(currentAttempt.extra_data).length > 0"
class="extra-block"
>
<summary class="extra-toggle">
额外信息
</summary>
<pre class="extra-json">{{ JSON.stringify(currentAttempt.extra_data, null, 2) }}</pre> <pre class="extra-json">{{ JSON.stringify(currentAttempt.extra_data, null, 2) }}</pre>
</details> </details>
</div> </div>
@@ -224,9 +289,14 @@
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<Card v-else class="border-dashed"> <Card
v-else
class="border-dashed"
>
<div class="p-8 text-center"> <div class="p-8 text-center">
<p class="text-sm text-muted-foreground">暂无追踪数据</p> <p class="text-sm text-muted-foreground">
暂无追踪数据
</p>
</div> </div>
</Card> </Card>
</div> </div>

View File

@@ -1,8 +1,13 @@
<template> <template>
<Card class="p-4"> <Card class="p-4">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<p class="text-sm font-semibold">{{ title }}</p> <p class="text-sm font-semibold">
<div v-if="displayLegendItems.length > 0" class="flex items-center gap-2 flex-wrap justify-end text-[11px]"> {{ title }}
</p>
<div
v-if="displayLegendItems.length > 0"
class="flex items-center gap-2 flex-wrap justify-end text-[11px]"
>
<div <div
v-for="item in displayLegendItems" v-for="item in displayLegendItems"
:key="item.id" :key="item.id"
@@ -14,18 +19,35 @@
/> />
<span class="text-muted-foreground">{{ item.name }}</span> <span class="text-muted-foreground">{{ item.name }}</span>
</div> </div>
<span v-if="hiddenLegendCount > 0" class="text-muted-foreground"> <span
v-if="hiddenLegendCount > 0"
class="text-muted-foreground"
>
+{{ hiddenLegendCount }} 更多 +{{ hiddenLegendCount }} 更多
</span> </span>
</div> </div>
</div> </div>
<div v-if="loading" class="h-[160px] flex items-center justify-center"> <div
<div class="text-sm text-muted-foreground">Loading...</div> v-if="loading"
class="h-[160px] flex items-center justify-center"
>
<div class="text-sm text-muted-foreground">
Loading...
</div> </div>
<div v-else-if="hasData" class="h-[160px]">
<ScatterChart :data="chartData" :options="chartOptions" />
</div> </div>
<div v-else class="h-[160px] flex items-center justify-center text-sm text-muted-foreground"> <div
v-else-if="hasData"
class="h-[160px]"
>
<ScatterChart
:data="chartData"
:options="chartOptions"
/>
</div>
<div
v-else
class="h-[160px] flex items-center justify-center text-sm text-muted-foreground"
>
暂无请求间隔数据 暂无请求间隔数据
</div> </div>
</Card> </Card>
@@ -147,7 +169,7 @@ function formatModelName(model: string): string {
} }
} }
// 其他模型保持原样但截断 // 其他模型保持原样但截断
return model.length > 20 ? model.slice(0, 17) + '...' : model return model.length > 20 ? `${model.slice(0, 17) }...` : model
} }
// 构建图表数据 // 构建图表数据

View File

@@ -8,7 +8,10 @@
@click.self="handleClose" @click.self="handleClose"
> >
<!-- 背景遮罩 --> <!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="handleClose"></div> <div
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
@click="handleClose"
/>
<!-- 抽屉内容 --> <!-- 抽屉内容 -->
<Card class="relative h-full w-[800px] max-w-[90vw] rounded-none shadow-2xl flex flex-col"> <Card class="relative h-full w-[800px] max-w-[90vw] rounded-none shadow-2xl flex flex-col">
@@ -17,19 +20,46 @@
<!-- 第一行标题模型状态操作按钮 --> <!-- 第一行标题模型状态操作按钮 -->
<div class="flex items-center justify-between gap-4 mb-3"> <div class="flex items-center justify-between gap-4 mb-3">
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
<h3 class="text-lg font-semibold">请求详情</h3> <h3 class="text-lg font-semibold">
请求详情
</h3>
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded"> <div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
<span>{{ detail?.model || '-' }}</span> <span>{{ detail?.model || '-' }}</span>
<template v-if="detail?.target_model"> <template v-if="detail?.target_model">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3 flex-shrink-0"> <svg
<path fill-rule="evenodd" d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z" clip-rule="evenodd" /> xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
clip-rule="evenodd"
/>
</svg> </svg>
<span>{{ detail.target_model }}</span> <span>{{ detail.target_model }}</span>
</template> </template>
</div> </div>
<Badge v-if="detail?.status_code === 200" variant="success">{{ detail.status_code }}</Badge> <Badge
<Badge v-else-if="detail" variant="destructive">{{ detail.status_code }}</Badge> v-if="detail?.status_code === 200"
<Badge variant="outline" class="text-xs" v-if="detail">{{ detail.is_stream ? '流式' : '标准' }}</Badge> variant="success"
>
{{ detail.status_code }}
</Badge>
<Badge
v-else-if="detail"
variant="destructive"
>
{{ detail.status_code }}
</Badge>
<Badge
v-if="detail"
variant="outline"
class="text-xs"
>
{{ detail.is_stream ? '流式' : '标准' }}
</Badge>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<Button <Button
@@ -37,18 +67,30 @@
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
:disabled="loading" :disabled="loading"
@click="refreshDetail"
title="刷新" title="刷新"
@click="refreshDetail"
> >
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" /> <RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': loading }"
/>
</Button> </Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="handleClose" title="关闭"> <Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="关闭"
@click="handleClose"
>
<X class="w-4 h-4" /> <X class="w-4 h-4" />
</Button> </Button>
</div> </div>
</div> </div>
<!-- 第二行关键元信息 --> <!-- 第二行关键元信息 -->
<div v-if="detail" class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div
v-if="detail"
class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"
>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<span class="font-medium text-foreground">ID:</span> <span class="font-medium text-foreground">ID:</span>
<span class="font-mono">{{ detail.request_id || detail.id }}</span> <span class="font-mono">{{ detail.request_id || detail.id }}</span>
@@ -67,21 +109,32 @@
<!-- 可滚动内容区域 --> <!-- 可滚动内容区域 -->
<div class="flex-1 min-h-0 overflow-y-auto px-6 py-4 scrollbar-stable"> <div class="flex-1 min-h-0 overflow-y-auto px-6 py-4 scrollbar-stable">
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="py-8 space-y-4"> <div
v-if="loading"
class="py-8 space-y-4"
>
<Skeleton class="h-8 w-full" /> <Skeleton class="h-8 w-full" />
<Skeleton class="h-32 w-full" /> <Skeleton class="h-32 w-full" />
<Skeleton class="h-64 w-full" /> <Skeleton class="h-64 w-full" />
</div> </div>
<!-- Error State --> <!-- Error State -->
<Card v-else-if="error" class="border-red-200 dark:border-red-800"> <Card
v-else-if="error"
class="border-red-200 dark:border-red-800"
>
<div class="p-4"> <div class="p-4">
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p> <p class="text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div> </div>
</Card> </Card>
<!-- Detail Content --> <!-- Detail Content -->
<div v-else-if="detail" class="space-y-4"> <div
v-else-if="detail"
class="space-y-4"
>
<!-- 费用与性能概览 --> <!-- 费用与性能概览 -->
<Card> <Card>
<div class="p-4"> <div class="p-4">
@@ -93,7 +146,10 @@
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }} ${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
</span> </span>
</div> </div>
<Separator orientation="vertical" class="h-6 mx-6" /> <Separator
orientation="vertical"
class="h-6 mx-6"
/>
<div class="flex items-center"> <div class="flex items-center">
<span class="text-xs text-muted-foreground w-[56px]">响应时间</span> <span class="text-xs text-muted-foreground w-[56px]">响应时间</span>
<span class="text-lg font-bold">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).value : 'N/A' }}</span> <span class="text-lg font-bold">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).value : 'N/A' }}</span>
@@ -111,7 +167,11 @@
<span class="text-foreground">|</span> <span class="text-foreground">|</span>
<span>总输入上下文: <span class="font-mono font-medium text-foreground">{{ formatNumber(totalInputContext) }}</span></span> <span>总输入上下文: <span class="font-mono font-medium text-foreground">{{ formatNumber(totalInputContext) }}</span></span>
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ formatNumber(detail.cache_creation_input_tokens || 0) }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span> <span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ formatNumber(detail.cache_creation_input_tokens || 0) }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
<Badge v-if="displayTiers.length > 1" variant="outline" class="text-[10px] px-1.5 py-0 h-4"> <Badge
v-if="displayTiers.length > 1"
variant="outline"
class="text-[10px] px-1.5 py-0 h-4"
>
命中第 {{ currentTierIndex + 1 }} 命中第 {{ currentTierIndex + 1 }}
</Badge> </Badge>
</div> </div>
@@ -129,13 +189,20 @@
<!-- 阶梯标题行 --> <!-- 阶梯标题行 -->
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium" :class="index === currentTierIndex ? 'text-primary' : 'text-muted-foreground'"> <span
class="font-medium"
:class="index === currentTierIndex ? 'text-primary' : 'text-muted-foreground'"
>
{{ index + 1 }} {{ index + 1 }}
</span> </span>
<span class="text-muted-foreground"> <span class="text-muted-foreground">
{{ getTierRangeText(tier, index, displayTiers) }} {{ getTierRangeText(tier, index, displayTiers) }}
</span> </span>
<Badge v-if="index === currentTierIndex" variant="default" class="text-[10px] px-1.5 py-0 h-4"> <Badge
v-if="index === currentTierIndex"
variant="default"
class="text-[10px] px-1.5 py-0 h-4"
>
当前 当前
</Badge> </Badge>
</div> </div>
@@ -161,7 +228,10 @@
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.input || detail.input_tokens || 0 }}</span> <span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.input || detail.input_tokens || 0 }}</span>
<span class="text-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).toFixed(6) }}</span> <span class="text-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).toFixed(6) }}</span>
</div> </div>
<Separator orientation="vertical" class="h-4 mx-4" /> <Separator
orientation="vertical"
class="h-4 mx-4"
/>
<div class="flex items-center flex-1"> <div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">输出</span> <span class="text-xs text-muted-foreground w-[56px]">输出</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.output || detail.output_tokens || 0 }}</span> <span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.output || detail.output_tokens || 0 }}</span>
@@ -175,7 +245,10 @@
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_creation_input_tokens || 0 }}</span> <span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_creation_input_tokens || 0 }}</span>
<span class="text-xs font-mono">${{ (detail.cache_creation_cost || 0).toFixed(6) }}</span> <span class="text-xs font-mono">${{ (detail.cache_creation_cost || 0).toFixed(6) }}</span>
</div> </div>
<Separator orientation="vertical" class="h-4 mx-4" /> <Separator
orientation="vertical"
class="h-4 mx-4"
/>
<div class="flex items-center flex-1"> <div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">缓存读取</span> <span class="text-xs text-muted-foreground w-[56px]">缓存读取</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_read_input_tokens || 0 }}</span> <span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_read_input_tokens || 0 }}</span>
@@ -183,13 +256,19 @@
</div> </div>
</div> </div>
<!-- 按次计费 --> <!-- 按次计费 -->
<div v-if="detail.request_cost" class="flex items-center"> <div
v-if="detail.request_cost"
class="flex items-center"
>
<div class="flex items-center flex-1"> <div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">按次计费</span> <span class="text-xs text-muted-foreground w-[56px]">按次计费</span>
<span class="text-sm font-semibold font-mono flex-1 text-center"></span> <span class="text-sm font-semibold font-mono flex-1 text-center" />
<span class="text-xs font-mono">${{ detail.request_cost.toFixed(6) }}</span> <span class="text-xs font-mono">${{ detail.request_cost.toFixed(6) }}</span>
</div> </div>
<Separator orientation="vertical" class="h-4 mx-4 invisible" /> <Separator
orientation="vertical"
class="h-4 mx-4 invisible"
/>
<div class="flex items-center flex-1 invisible"> <div class="flex items-center flex-1 invisible">
<span class="text-xs text-muted-foreground w-[56px]">占位</span> <span class="text-xs text-muted-foreground w-[56px]">占位</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">0</span> <span class="text-sm font-semibold font-mono flex-1 text-center">0</span>
@@ -211,11 +290,18 @@
</div> </div>
<!-- 错误信息卡片 --> <!-- 错误信息卡片 -->
<Card v-if="detail.error_message" class="border-red-200 dark:border-red-800"> <Card
v-if="detail.error_message"
class="border-red-200 dark:border-red-800"
>
<div class="p-4"> <div class="p-4">
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">错误信息</h4> <h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
错误信息
</h4>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3"> <div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<p class="text-sm text-red-800 dark:text-red-300">{{ detail.error_message }}</p> <p class="text-sm text-red-800 dark:text-red-300">
{{ detail.error_message }}
</p>
</div> </div>
</div> </div>
</Card> </Card>
@@ -223,7 +309,10 @@
<!-- Tabs 区域 --> <!-- Tabs 区域 -->
<Card> <Card>
<div class="p-4"> <div class="p-4">
<Tabs v-model="activeTab" :default-value="activeTab"> <Tabs
v-model="activeTab"
:default-value="activeTab"
>
<!-- Tab + 图标工具栏同行 --> <!-- Tab + 图标工具栏同行 -->
<div class="flex items-center justify-between border-b pb-2 mb-3"> <div class="flex items-center justify-between border-b pb-2 mb-3">
<!-- 左侧 Tab --> <!-- 左侧 Tab -->
@@ -231,11 +320,11 @@
<button <button
v-for="tab in visibleTabs" v-for="tab in visibleTabs"
:key="tab.name" :key="tab.name"
@click="activeTab = tab.name"
class="px-3 py-1.5 text-sm transition-colors border-b-2 -mb-[9px]" class="px-3 py-1.5 text-sm transition-colors border-b-2 -mb-[9px]"
:class="activeTab === tab.name :class="activeTab === tab.name
? 'border-primary text-foreground font-medium' ? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground'" : 'border-transparent text-muted-foreground hover:text-foreground'"
@click="activeTab = tab.name"
> >
{{ tab.label }} {{ tab.label }}
</button> </button>
@@ -245,56 +334,71 @@
<!-- 请求头专用对比/客户端/提供商 切换组 --> <!-- 请求头专用对比/客户端/提供商 切换组 -->
<template v-if="activeTab === 'request-headers' && hasProviderHeaders"> <template v-if="activeTab === 'request-headers' && hasProviderHeaders">
<button <button
:title="'对比'" title="对比"
@click="viewMode = 'compare'"
class="p-1.5 rounded transition-colors" class="p-1.5 rounded transition-colors"
:class="viewMode === 'compare' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'" :class="viewMode === 'compare' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
@click="viewMode = 'compare'"
> >
<Columns2 class="w-4 h-4" /> <Columns2 class="w-4 h-4" />
</button> </button>
<button <button
:title="'客户端'" title="客户端"
@click="viewMode = 'formatted'; dataSource = 'client'"
class="p-1.5 rounded transition-colors" class="p-1.5 rounded transition-colors"
:class="viewMode === 'formatted' && dataSource === 'client' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'" :class="viewMode === 'formatted' && dataSource === 'client' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
@click="viewMode = 'formatted'; dataSource = 'client'"
> >
<Monitor class="w-4 h-4" /> <Monitor class="w-4 h-4" />
</button> </button>
<button <button
:title="'提供商'" title="提供商"
@click="viewMode = 'formatted'; dataSource = 'provider'"
class="p-1.5 rounded transition-colors" class="p-1.5 rounded transition-colors"
:class="viewMode === 'formatted' && dataSource === 'provider' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'" :class="viewMode === 'formatted' && dataSource === 'provider' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'"
@click="viewMode = 'formatted'; dataSource = 'provider'"
> >
<Server class="w-4 h-4" /> <Server class="w-4 h-4" />
</button> </button>
<Separator orientation="vertical" class="h-4 mx-1" /> <Separator
orientation="vertical"
class="h-4 mx-1"
/>
</template> </template>
<!-- 展开/收缩 --> <!-- 展开/收缩 -->
<button <button
:title="currentExpandDepth === 0 ? '展开全部' : '收缩全部'" :title="currentExpandDepth === 0 ? '展开全部' : '收缩全部'"
@click="currentExpandDepth === 0 ? expandAll() : collapseAll()"
class="p-1.5 rounded transition-colors" class="p-1.5 rounded transition-colors"
:class="viewMode === 'compare' :class="viewMode === 'compare'
? 'text-muted-foreground/40 cursor-not-allowed' ? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:bg-muted'" : 'text-muted-foreground hover:bg-muted'"
:disabled="viewMode === 'compare'" :disabled="viewMode === 'compare'"
@click="currentExpandDepth === 0 ? expandAll() : collapseAll()"
> >
<Maximize2 v-if="currentExpandDepth === 0" class="w-4 h-4" /> <Maximize2
<Minimize2 v-else class="w-4 h-4" /> v-if="currentExpandDepth === 0"
class="w-4 h-4"
/>
<Minimize2
v-else
class="w-4 h-4"
/>
</button> </button>
<!-- 复制 --> <!-- 复制 -->
<button <button
:title="copiedStates[activeTab] ? '已复制' : '复制'" :title="copiedStates[activeTab] ? '已复制' : '复制'"
@click="copyJsonToClipboard(activeTab)"
class="p-1.5 rounded transition-colors" class="p-1.5 rounded transition-colors"
:class="viewMode === 'compare' :class="viewMode === 'compare'
? 'text-muted-foreground/40 cursor-not-allowed' ? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:bg-muted'" : 'text-muted-foreground hover:bg-muted'"
:disabled="viewMode === 'compare'" :disabled="viewMode === 'compare'"
@click="copyJsonToClipboard(activeTab)"
> >
<Check v-if="copiedStates[activeTab]" class="w-4 h-4 text-green-500" /> <Check
<Copy v-else class="w-4 h-4" /> v-if="copiedStates[activeTab]"
class="w-4 h-4 text-green-500"
/>
<Copy
v-else
class="w-4 h-4"
/>
</button> </button>
</div> </div>
</div> </div>
@@ -613,9 +717,9 @@ function formatApiFormat(format: string | null | undefined): string {
function formatNumber(num: number): string { function formatNumber(num: number): string {
if (num >= 1_000_000) { if (num >= 1_000_000) {
return (num / 1_000_000).toFixed(1) + 'M' return `${(num / 1_000_000).toFixed(1) }M`
} else if (num >= 1_000) { } else if (num >= 1_000) {
return (num / 1_000).toFixed(1) + 'K' return `${(num / 1_000).toFixed(1) }K`
} }
return num.toLocaleString() return num.toLocaleString()
} }

View File

@@ -1,16 +1,25 @@
<template> <template>
<div> <div>
<div v-if="!data || (typeof data === 'object' && Object.keys(data).length === 0)" class="text-sm text-muted-foreground"> <div
v-if="!data || (typeof data === 'object' && Object.keys(data).length === 0)"
class="text-sm text-muted-foreground"
>
{{ emptyMessage }} {{ emptyMessage }}
</div> </div>
<!-- 纯字符串数据 JSON 对象 --> <!-- 纯字符串数据 JSON 对象 -->
<Card v-else-if="typeof data === 'string'" class="bg-muted/30 overflow-hidden"> <Card
v-else-if="typeof data === 'string'"
class="bg-muted/30 overflow-hidden"
>
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto"> <div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
<pre class="text-xs font-mono whitespace-pre-wrap">{{ data }}</pre> <pre class="text-xs font-mono whitespace-pre-wrap">{{ data }}</pre>
</div> </div>
</Card> </Card>
<!-- JSON 响应 HTML 错误页面 --> <!-- JSON 响应 HTML 错误页面 -->
<Card v-else-if="data.raw_response && data.metadata?.parse_error" class="bg-muted/30 overflow-hidden"> <Card
v-else-if="data.raw_response && data.metadata?.parse_error"
class="bg-muted/30 overflow-hidden"
>
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800"> <div class="p-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<span class="text-amber-600 dark:text-amber-400 text-sm font-medium">Warning: 响应解析失败</span> <span class="text-amber-600 dark:text-amber-400 text-sm font-medium">Warning: 响应解析失败</span>
@@ -21,12 +30,24 @@
<pre class="text-xs font-mono whitespace-pre-wrap text-muted-foreground">{{ data.raw_response }}</pre> <pre class="text-xs font-mono whitespace-pre-wrap text-muted-foreground">{{ data.raw_response }}</pre>
</div> </div>
</Card> </Card>
<Card v-else class="bg-muted/30 overflow-hidden"> <Card
v-else
class="bg-muted/30 overflow-hidden"
>
<!-- JSON 查看器 --> <!-- JSON 查看器 -->
<div class="json-viewer" :class="{ 'theme-dark': isDark }"> <div
class="json-viewer"
:class="{ 'theme-dark': isDark }"
>
<div class="json-lines"> <div class="json-lines">
<template v-for="line in visibleLines" :key="line.displayId"> <template
<div class="json-line" :class="{ 'has-fold': line.canFold }"> v-for="line in visibleLines"
:key="line.displayId"
>
<div
class="json-line"
:class="{ 'has-fold': line.canFold }"
>
<!-- 行号区域包含折叠按钮 --> <!-- 行号区域包含折叠按钮 -->
<div class="line-number-area"> <div class="line-number-area">
<span <span
@@ -34,22 +55,31 @@
class="fold-button" class="fold-button"
@click="toggleFold(line.blockId)" @click="toggleFold(line.blockId)"
> >
<ChevronRight v-if="collapsedBlocks.has(line.blockId)" class="fold-icon" /> <ChevronRight
<ChevronDown v-else class="fold-icon" /> v-if="collapsedBlocks.has(line.blockId)"
class="fold-icon"
/>
<ChevronDown
v-else
class="fold-icon"
/>
</span> </span>
<span class="line-number">{{ line.displayLineNumber }}</span> <span class="line-number">{{ line.displayLineNumber }}</span>
</div> </div>
<!-- 内容区域 --> <!-- 内容区域 -->
<div class="line-content-area"> <div class="line-content-area">
<!-- 缩进 --> <!-- 缩进 -->
<span class="indent" :style="{ width: `${line.indent * 16}px` }"></span> <span
class="indent"
:style="{ width: `${line.indent * 16}px` }"
/>
<!-- 内容 --> <!-- 内容 -->
<span <span
class="line-content" class="line-content"
:class="{ 'clickable-collapsed': line.canFold && collapsedBlocks.has(line.blockId) }" :class="{ 'clickable-collapsed': line.canFold && collapsedBlocks.has(line.blockId) }"
@click="line.canFold && collapsedBlocks.has(line.blockId) && toggleFold(line.blockId)" @click="line.canFold && collapsedBlocks.has(line.blockId) && toggleFold(line.blockId)"
v-html="getDisplayHtml(line)" v-html="getDisplayHtml(line)"
></span> />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -2,10 +2,16 @@
<div> <div>
<!-- 对比模式 - 并排 Diff --> <!-- 对比模式 - 并排 Diff -->
<div v-show="viewMode === 'compare'"> <div v-show="viewMode === 'compare'">
<div v-if="!detail.request_headers && !detail.provider_request_headers" class="text-sm text-muted-foreground"> <div
v-if="!detail.request_headers && !detail.provider_request_headers"
class="text-sm text-muted-foreground"
>
无请求头信息 无请求头信息
</div> </div>
<Card v-else class="bg-muted/30 overflow-hidden"> <Card
v-else
class="bg-muted/30 overflow-hidden"
>
<!-- Diff 头部 --> <!-- Diff 头部 -->
<div class="flex border-b bg-muted/50"> <div class="flex border-b bg-muted/50">
<div class="flex-1 px-3 py-2 text-xs text-muted-foreground border-r flex items-center justify-between"> <div class="flex-1 px-3 py-2 text-xs text-muted-foreground border-r flex items-center justify-between">
@@ -23,25 +29,40 @@
<div class="flex font-mono text-xs"> <div class="flex font-mono text-xs">
<!-- 左侧客户端 --> <!-- 左侧客户端 -->
<div class="flex-1 border-r"> <div class="flex-1 border-r">
<template v-for="entry in sortedEntries" :key="'left-' + entry.key"> <template
v-for="entry in sortedEntries"
:key="'left-' + entry.key"
>
<!-- 删除的行 --> <!-- 删除的行 -->
<div v-if="entry.status === 'removed'" class="flex items-start bg-destructive/10 px-3 py-0.5"> <div
v-if="entry.status === 'removed'"
class="flex items-start bg-destructive/10 px-3 py-0.5"
>
<span class="text-destructive"> <span class="text-destructive">
"{{ entry.key }}": "{{ entry.clientValue }}" "{{ entry.key }}": "{{ entry.clientValue }}"
</span> </span>
</div> </div>
<!-- 修改的行 - 旧值 --> <!-- 修改的行 - 旧值 -->
<div v-else-if="entry.status === 'modified'" class="flex items-start bg-amber-500/10 px-3 py-0.5"> <div
v-else-if="entry.status === 'modified'"
class="flex items-start bg-amber-500/10 px-3 py-0.5"
>
<span class="text-amber-600 dark:text-amber-400"> <span class="text-amber-600 dark:text-amber-400">
"{{ entry.key }}": "{{ entry.clientValue }}" "{{ entry.key }}": "{{ entry.clientValue }}"
</span> </span>
</div> </div>
<!-- 新增的行 - 左侧空白占位 --> <!-- 新增的行 - 左侧空白占位 -->
<div v-else-if="entry.status === 'added'" class="flex items-start bg-muted/30 px-3 py-0.5"> <div
v-else-if="entry.status === 'added'"
class="flex items-start bg-muted/30 px-3 py-0.5"
>
<span class="text-muted-foreground/30 italic"></span> <span class="text-muted-foreground/30 italic"></span>
</div> </div>
<!-- 未变化的行 --> <!-- 未变化的行 -->
<div v-else class="flex items-start px-3 py-0.5 hover:bg-muted/50"> <div
v-else
class="flex items-start px-3 py-0.5 hover:bg-muted/50"
>
<span class="text-muted-foreground"> <span class="text-muted-foreground">
"{{ entry.key }}": "{{ entry.clientValue }}" "{{ entry.key }}": "{{ entry.clientValue }}"
</span> </span>
@@ -50,27 +71,42 @@
</div> </div>
<!-- 右侧提供商 --> <!-- 右侧提供商 -->
<div class="flex-1"> <div class="flex-1">
<template v-for="entry in sortedEntries" :key="'right-' + entry.key"> <template
v-for="entry in sortedEntries"
:key="'right-' + entry.key"
>
<!-- 删除的行 - 右侧空白占位 --> <!-- 删除的行 - 右侧空白占位 -->
<div v-if="entry.status === 'removed'" class="flex items-start bg-muted/30 px-3 py-0.5"> <div
v-if="entry.status === 'removed'"
class="flex items-start bg-muted/30 px-3 py-0.5"
>
<span class="text-muted-foreground/50 line-through"> <span class="text-muted-foreground/50 line-through">
"{{ entry.key }}": "{{ entry.clientValue }}" "{{ entry.key }}": "{{ entry.clientValue }}"
</span> </span>
</div> </div>
<!-- 修改的行 - 新值 --> <!-- 修改的行 - 新值 -->
<div v-else-if="entry.status === 'modified'" class="flex items-start bg-amber-500/10 px-3 py-0.5"> <div
v-else-if="entry.status === 'modified'"
class="flex items-start bg-amber-500/10 px-3 py-0.5"
>
<span class="text-amber-600 dark:text-amber-400"> <span class="text-amber-600 dark:text-amber-400">
"{{ entry.key }}": "{{ entry.providerValue }}" "{{ entry.key }}": "{{ entry.providerValue }}"
</span> </span>
</div> </div>
<!-- 新增的行 --> <!-- 新增的行 -->
<div v-else-if="entry.status === 'added'" class="flex items-start bg-green-500/10 px-3 py-0.5"> <div
v-else-if="entry.status === 'added'"
class="flex items-start bg-green-500/10 px-3 py-0.5"
>
<span class="text-green-600 dark:text-green-400"> <span class="text-green-600 dark:text-green-400">
"{{ entry.key }}": "{{ entry.providerValue }}" "{{ entry.key }}": "{{ entry.providerValue }}"
</span> </span>
</div> </div>
<!-- 未变化的行 --> <!-- 未变化的行 -->
<div v-else class="flex items-start px-3 py-0.5 hover:bg-muted/50"> <div
v-else
class="flex items-start px-3 py-0.5 hover:bg-muted/50"
>
<span class="text-muted-foreground"> <span class="text-muted-foreground">
"{{ entry.key }}": "{{ entry.providerValue }}" "{{ entry.key }}": "{{ entry.providerValue }}"
</span> </span>
@@ -95,10 +131,16 @@
<!-- 原始模式 --> <!-- 原始模式 -->
<div v-show="viewMode === 'raw'"> <div v-show="viewMode === 'raw'">
<div v-if="!currentHeaderData || Object.keys(currentHeaderData).length === 0" class="text-sm text-muted-foreground"> <div
v-if="!currentHeaderData || Object.keys(currentHeaderData).length === 0"
class="text-sm text-muted-foreground"
>
无请求头信息 无请求头信息
</div> </div>
<Card v-else class="bg-muted/30"> <Card
v-else
class="bg-muted/30"
>
<div class="p-4 overflow-x-auto"> <div class="p-4 overflow-x-auto">
<pre class="text-xs font-mono whitespace-pre-wrap">{{ JSON.stringify(currentHeaderData, null, 2) }}</pre> <pre class="text-xs font-mono whitespace-pre-wrap">{{ JSON.stringify(currentHeaderData, null, 2) }}</pre>
</div> </div>

View File

@@ -1,41 +1,66 @@
<template> <template>
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="px-3 py-2 border-b"> <div class="px-3 py-2 border-b">
<h3 class="text-sm font-medium">按API格式分析</h3> <h3 class="text-sm font-medium">
按API格式分析
</h3>
</div> </div>
<Table class="text-sm"> <Table class="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="h-8 px-2">API格式</TableHead> <TableHead class="h-8 px-2">
<TableHead class="h-8 px-2 text-right">请求数</TableHead> API格式
<TableHead class="h-8 px-2 text-right">Tokens</TableHead> </TableHead>
<TableHead class="h-8 px-2 text-right">费用</TableHead> <TableHead class="h-8 px-2 text-right">
<TableHead class="h-8 px-2 text-right">平均响应</TableHead> 请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
平均响应
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-if="data.length === 0"> <TableRow v-if="data.length === 0">
<TableCell :colspan="5" class="text-center py-6 text-muted-foreground px-2"> <TableCell
:colspan="5"
class="text-center py-6 text-muted-foreground px-2"
>
暂无API格式统计数据 暂无API格式统计数据
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-for="item in data" :key="item.api_format"> <TableRow
v-for="item in data"
:key="item.api_format"
>
<TableCell class="font-medium py-2 px-2"> <TableCell class="font-medium py-2 px-2">
{{ formatApiFormat(item.api_format) }} {{ formatApiFormat(item.api_format) }}
</TableCell> </TableCell>
<TableCell class="text-right py-2 px-2">{{ item.request_count }}</TableCell> <TableCell class="text-right py-2 px-2">
{{ item.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2"> <TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(item.total_tokens) }}</span> <span>{{ formatTokens(item.total_tokens) }}</span>
</TableCell> </TableCell>
<TableCell class="text-right py-2 px-2"> <TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5"> <div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span> <span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
<span v-if="isAdmin && item.actual_cost !== undefined" class="text-muted-foreground text-[10px]"> <span
v-if="isAdmin && item.actual_cost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(item.actual_cost) }} {{ formatCurrency(item.actual_cost) }}
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">{{ item.avgResponseTime }}</TableCell> <TableCell class="text-right text-muted-foreground py-2 px-2">
{{ item.avgResponseTime }}
</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
@@ -54,6 +79,11 @@ import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency } from '@/utils/format'
import type { ApiFormatStatsItem } from '../types' import type { ApiFormatStatsItem } from '../types'
defineProps<{
data: ApiFormatStatsItem[]
isAdmin: boolean
}>()
// 格式化 API 格式显示名称 // 格式化 API 格式显示名称
function formatApiFormat(format: string): string { function formatApiFormat(format: string): string {
const formatMap: Record<string, string> = { const formatMap: Record<string, string> = {
@@ -67,8 +97,4 @@ function formatApiFormat(format: string): string {
return formatMap[format.toUpperCase()] || format return formatMap[format.toUpperCase()] || format
} }
defineProps<{
data: ApiFormatStatsItem[]
isAdmin: boolean
}>()
</script> </script>

View File

@@ -1,41 +1,66 @@
<template> <template>
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="px-3 py-2 border-b"> <div class="px-3 py-2 border-b">
<h3 class="text-sm font-medium">按模型分析</h3> <h3 class="text-sm font-medium">
按模型分析
</h3>
</div> </div>
<Table class="text-sm"> <Table class="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="h-8 px-2">模型</TableHead> <TableHead class="h-8 px-2">
<TableHead class="h-8 px-2 text-right">请求数</TableHead> 模型
<TableHead class="h-8 px-2 text-right">Tokens</TableHead> </TableHead>
<TableHead class="h-8 px-2 text-right">费用</TableHead> <TableHead class="h-8 px-2 text-right">
<TableHead class="h-8 px-2 text-right">效率</TableHead> 请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
效率
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-if="data.length === 0"> <TableRow v-if="data.length === 0">
<TableCell :colspan="5" class="text-center py-6 text-muted-foreground px-2"> <TableCell
:colspan="5"
class="text-center py-6 text-muted-foreground px-2"
>
暂无模型统计数据 暂无模型统计数据
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-for="model in data" :key="model.model"> <TableRow
v-for="model in data"
:key="model.model"
>
<TableCell class="font-medium py-2 px-2"> <TableCell class="font-medium py-2 px-2">
{{ model.model.replace('claude-', '') }} {{ model.model.replace('claude-', '') }}
</TableCell> </TableCell>
<TableCell class="text-right py-2 px-2">{{ model.request_count }}</TableCell> <TableCell class="text-right py-2 px-2">
{{ model.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2"> <TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(model.total_tokens) }}</span> <span>{{ formatTokens(model.total_tokens) }}</span>
</TableCell> </TableCell>
<TableCell class="text-right py-2 px-2"> <TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5"> <div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span> <span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
<span v-if="isAdmin && model.actual_cost !== undefined" class="text-muted-foreground text-[10px]"> <span
v-if="isAdmin && model.actual_cost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(model.actual_cost) }} {{ formatCurrency(model.actual_cost) }}
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">{{ model.costPerToken }}</TableCell> <TableCell class="text-right text-muted-foreground py-2 px-2">
{{ model.costPerToken }}
</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -1,35 +1,62 @@
<template> <template>
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="px-3 py-2 border-b"> <div class="px-3 py-2 border-b">
<h3 class="text-sm font-medium">按提供商分析</h3> <h3 class="text-sm font-medium">
按提供商分析
</h3>
</div> </div>
<Table class="text-sm"> <Table class="text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="h-8 px-2">提供商</TableHead> <TableHead class="h-8 px-2">
<TableHead class="h-8 px-2 text-right">请求数</TableHead> 提供商
<TableHead class="h-8 px-2 text-right">Tokens</TableHead> </TableHead>
<TableHead class="h-8 px-2 text-right">费用</TableHead> <TableHead class="h-8 px-2 text-right">
<TableHead class="h-8 px-2 text-right">成功率</TableHead> 请求数
<TableHead class="h-8 px-2 text-right">平均响应</TableHead> </TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
成功率
</TableHead>
<TableHead class="h-8 px-2 text-right">
平均响应
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-if="data.length === 0"> <TableRow v-if="data.length === 0">
<TableCell :colspan="6" class="text-center py-6 text-muted-foreground px-2"> <TableCell
:colspan="6"
class="text-center py-6 text-muted-foreground px-2"
>
暂无提供商统计数据 暂无提供商统计数据
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-for="provider in data" :key="provider.provider"> <TableRow
<TableCell class="font-medium py-2 px-2">{{ provider.provider }}</TableCell> v-for="provider in data"
<TableCell class="text-right py-2 px-2">{{ provider.requests }}</TableCell> :key="provider.provider"
>
<TableCell class="font-medium py-2 px-2">
{{ provider.provider }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ provider.requests }}
</TableCell>
<TableCell class="text-right py-2 px-2"> <TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(provider.totalTokens) }}</span> <span>{{ formatTokens(provider.totalTokens) }}</span>
</TableCell> </TableCell>
<TableCell class="text-right py-2 px-2"> <TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5"> <div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span> <span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
<span v-if="isAdmin && provider.actualCost !== undefined" class="text-muted-foreground text-[10px]"> <span
v-if="isAdmin && provider.actualCost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(provider.actualCost) }} {{ formatCurrency(provider.actualCost) }}
</span> </span>
</div> </div>
@@ -37,7 +64,9 @@
<TableCell class="text-right py-2 px-2"> <TableCell class="text-right py-2 px-2">
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span> <span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
</TableCell> </TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">{{ provider.avgResponseTime }}</TableCell> <TableCell class="text-right text-muted-foreground py-2 px-2">
{{ provider.avgResponseTime }}
</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
@@ -56,14 +85,15 @@ import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency } from '@/utils/format'
import type { ProviderStatsItem } from '../types' import type { ProviderStatsItem } from '../types'
defineProps<{
data: ProviderStatsItem[]
isAdmin: boolean
}>()
// 成功率样式 - 简化为两种状态 // 成功率样式 - 简化为两种状态
function getSuccessRateClass(rate: number): string { function getSuccessRateClass(rate: number): string {
if (rate < 90) return 'text-destructive' if (rate < 90) return 'text-destructive'
return '' // 默认颜色 return '' // 默认颜色
} }
defineProps<{
data: ProviderStatsItem[]
isAdmin: boolean
}>()
</script> </script>

View File

@@ -3,19 +3,29 @@
<template #actions> <template #actions>
<!-- 时间段筛选 --> <!-- 时间段筛选 -->
<Select <Select
:model-value="selectedPeriod"
v-model:open="periodSelectOpen" v-model:open="periodSelectOpen"
:model-value="selectedPeriod"
@update:model-value="$emit('update:selectedPeriod', $event)" @update:model-value="$emit('update:selectedPeriod', $event)"
> >
<SelectTrigger class="w-32 h-8 text-xs border-border/60"> <SelectTrigger class="w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="选择时间段" /> <SelectValue placeholder="选择时间段" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="today">今天</SelectItem> <SelectItem value="today">
<SelectItem value="yesterday">昨天</SelectItem> 今天
<SelectItem value="last7days">最近7天</SelectItem> </SelectItem>
<SelectItem value="last30days">最近30天</SelectItem> <SelectItem value="yesterday">
<SelectItem value="last90days">最近90天</SelectItem> 昨天
</SelectItem>
<SelectItem value="last7days">
最近7天
</SelectItem>
<SelectItem value="last30days">
最近30天
</SelectItem>
<SelectItem value="last90days">
最近90天
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -25,16 +35,22 @@
<!-- 用户筛选仅管理员可见 --> <!-- 用户筛选仅管理员可见 -->
<Select <Select
v-if="isAdmin && availableUsers.length > 0" v-if="isAdmin && availableUsers.length > 0"
:model-value="filterUser"
v-model:open="filterUserSelectOpen" v-model:open="filterUserSelectOpen"
:model-value="filterUser"
@update:model-value="$emit('update:filterUser', $event)" @update:model-value="$emit('update:filterUser', $event)"
> >
<SelectTrigger class="w-36 h-8 text-xs border-border/60"> <SelectTrigger class="w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="全部用户" /> <SelectValue placeholder="全部用户" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__all__">全部用户</SelectItem> <SelectItem value="__all__">
<SelectItem v-for="user in availableUsers" :key="user.id" :value="user.id"> 全部用户
</SelectItem>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }} {{ user.username || user.email }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -42,16 +58,22 @@
<!-- 模型筛选 --> <!-- 模型筛选 -->
<Select <Select
:model-value="filterModel"
v-model:open="filterModelSelectOpen" v-model:open="filterModelSelectOpen"
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)" @update:model-value="$emit('update:filterModel', $event)"
> >
<SelectTrigger class="w-40 h-8 text-xs border-border/60"> <SelectTrigger class="w-40 h-8 text-xs border-border/60">
<SelectValue placeholder="全部模型" /> <SelectValue placeholder="全部模型" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__all__">全部模型</SelectItem> <SelectItem value="__all__">
<SelectItem v-for="model in availableModels" :key="model" :value="model"> 全部模型
</SelectItem>
<SelectItem
v-for="model in availableModels"
:key="model"
:value="model"
>
{{ model.replace('claude-', '') }} {{ model.replace('claude-', '') }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -59,16 +81,22 @@
<!-- 提供商筛选 --> <!-- 提供商筛选 -->
<Select <Select
:model-value="filterProvider"
v-model:open="filterProviderSelectOpen" v-model:open="filterProviderSelectOpen"
:model-value="filterProvider"
@update:model-value="$emit('update:filterProvider', $event)" @update:model-value="$emit('update:filterProvider', $event)"
> >
<SelectTrigger class="w-32 h-8 text-xs border-border/60"> <SelectTrigger class="w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="全部提供商" /> <SelectValue placeholder="全部提供商" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__all__">全部提供商</SelectItem> <SelectItem value="__all__">
<SelectItem v-for="provider in availableProviders" :key="provider" :value="provider"> 全部提供商
</SelectItem>
<SelectItem
v-for="provider in availableProviders"
:key="provider"
:value="provider"
>
{{ provider }} {{ provider }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -76,20 +104,32 @@
<!-- 状态筛选 --> <!-- 状态筛选 -->
<Select <Select
:model-value="filterStatus"
v-model:open="filterStatusSelectOpen" v-model:open="filterStatusSelectOpen"
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)" @update:model-value="$emit('update:filterStatus', $event)"
> >
<SelectTrigger class="w-28 h-8 text-xs border-border/60"> <SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" /> <SelectValue placeholder="全部状态" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__all__">全部状态</SelectItem> <SelectItem value="__all__">
<SelectItem value="active">进行中</SelectItem> 全部状态
<SelectItem value="pending">等待中</SelectItem> </SelectItem>
<SelectItem value="streaming">流式传输</SelectItem> <SelectItem value="active">
<SelectItem value="completed">已完成</SelectItem> 进行中
<SelectItem value="failed">已失败</SelectItem> </SelectItem>
<SelectItem value="pending">
等待中
</SelectItem>
<SelectItem value="streaming">
流式传输
</SelectItem>
<SelectItem value="completed">
已完成
</SelectItem>
<SelectItem value="failed">
已失败
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -97,58 +137,113 @@
<div class="h-4 w-px bg-border" /> <div class="h-4 w-px bg-border" />
<!-- 刷新按钮 --> <!-- 刷新按钮 -->
<RefreshButton :loading="loading" @click="$emit('refresh')" /> <RefreshButton
:loading="loading"
@click="$emit('refresh')"
/>
</template> </template>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold w-[70px]">时间</TableHead> <TableHead class="h-12 font-semibold w-[70px]">
<TableHead v-if="isAdmin" class="h-12 font-semibold w-[100px]">用户</TableHead> 时间
<TableHead class="h-12 font-semibold w-[140px]">模型</TableHead> </TableHead>
<TableHead v-if="isAdmin" class="h-12 font-semibold w-[100px]">提供商</TableHead> <TableHead
<TableHead class="h-12 font-semibold w-[80px]">API格式</TableHead> v-if="isAdmin"
<TableHead class="h-12 font-semibold w-[50px] text-center">类型</TableHead> class="h-12 font-semibold w-[100px]"
<TableHead class="h-12 font-semibold w-[140px] text-right">Tokens</TableHead> >
<TableHead class="h-12 font-semibold w-[100px] text-right">费用</TableHead> 用户
</TableHead>
<TableHead class="h-12 font-semibold w-[140px]">
模型
</TableHead>
<TableHead
v-if="isAdmin"
class="h-12 font-semibold w-[100px]"
>
提供商
</TableHead>
<TableHead class="h-12 font-semibold w-[80px]">
API格式
</TableHead>
<TableHead class="h-12 font-semibold w-[50px] text-center">
类型
</TableHead>
<TableHead class="h-12 font-semibold w-[140px] text-right">
Tokens
</TableHead>
<TableHead class="h-12 font-semibold w-[100px] text-right">
费用
</TableHead>
<TableHead class="h-12 font-semibold w-[70px] text-right"> <TableHead class="h-12 font-semibold w-[70px] text-right">
<div class="inline-block max-w-[2rem] leading-tight">响应时间</div> <div class="inline-block max-w-[2rem] leading-tight">
响应时间
</div>
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-if="records.length === 0"> <TableRow v-if="records.length === 0">
<TableCell :colspan="isAdmin ? 9 : 7" class="text-center py-12 text-muted-foreground"> <TableCell
:colspan="isAdmin ? 9 : 7"
class="text-center py-12 text-muted-foreground"
>
暂无请求记录 暂无请求记录
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow <TableRow
v-else
v-for="record in records" v-for="record in records"
v-else
:key="record.id" :key="record.id"
:class="isAdmin ? 'cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]' : 'border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]'"
@mousedown="handleMouseDown" @mousedown="handleMouseDown"
@click="handleRowClick($event, record.id)" @click="handleRowClick($event, record.id)"
:class="isAdmin ? 'cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]' : 'border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]'"
> >
<TableCell class="text-xs py-4 w-[70px]"> <TableCell class="text-xs py-4 w-[70px]">
{{ formatDateTime(record.created_at) }} {{ formatDateTime(record.created_at) }}
</TableCell> </TableCell>
<TableCell v-if="isAdmin" class="py-4 w-[100px] truncate" :title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"> <TableCell
v-if="isAdmin"
class="py-4 w-[100px] truncate"
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
>
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }} {{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
</TableCell> </TableCell>
<TableCell class="font-medium py-4 w-[140px]" :title="getModelTooltip(record)"> <TableCell
<div v-if="getActualModel(record)" class="flex flex-col text-xs gap-0.5"> class="font-medium py-4 w-[140px]"
:title="getModelTooltip(record)"
>
<div
v-if="getActualModel(record)"
class="flex flex-col text-xs gap-0.5"
>
<div class="flex items-center gap-1 truncate"> <div class="flex items-center gap-1 truncate">
<span class="truncate">{{ record.model }}</span> <span class="truncate">{{ record.model }}</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3 text-muted-foreground flex-shrink-0"> <svg
<path fill-rule="evenodd" d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z" clip-rule="evenodd" /> xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 text-muted-foreground flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
clip-rule="evenodd"
/>
</svg> </svg>
</div> </div>
<span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span> <span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span>
</div> </div>
<span v-else class="truncate block">{{ record.model }}</span> <span
v-else
class="truncate block"
>{{ record.model }}</span>
</TableCell> </TableCell>
<TableCell v-if="isAdmin" class="py-4 w-[60px]"> <TableCell
v-if="isAdmin"
class="py-4 w-[60px]"
>
<div class="flex flex-col text-xs gap-0.5"> <div class="flex flex-col text-xs gap-0.5">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span>{{ record.provider }}</span> <span>{{ record.provider }}</span>
@@ -157,14 +252,30 @@
class="inline-flex items-center justify-center w-4 h-4 text-xs text-amber-600 dark:text-amber-400" class="inline-flex items-center justify-center w-4 h-4 text-xs text-amber-600 dark:text-amber-400"
title="此请求发生了 Provider 切换" title="此请求发生了 Provider 切换"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"> <svg
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" /> xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg> </svg>
</span> </span>
</div> </div>
<span v-if="record.api_key_name" class="text-muted-foreground truncate" :title="record.api_key_name"> <span
v-if="record.api_key_name"
class="text-muted-foreground truncate"
:title="record.api_key_name"
>
{{ record.api_key_name }} {{ record.api_key_name }}
<span v-if="record.rate_multiplier && record.rate_multiplier !== 1.0" class="text-foreground/60">({{ record.rate_multiplier }}x)</span> <span
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-foreground/60"
>({{ record.rate_multiplier }}x)</span>
</span> </span>
</div> </div>
</TableCell> </TableCell>
@@ -176,23 +287,46 @@
> >
{{ formatApiFormat(record.api_format) }} {{ formatApiFormat(record.api_format) }}
</span> </span>
<span v-else class="text-muted-foreground text-xs">-</span> <span
v-else
class="text-muted-foreground text-xs"
>-</span>
</TableCell> </TableCell>
<TableCell class="text-center py-4 w-[50px]"> <TableCell class="text-center py-4 w-[50px]">
<!-- 优先显示请求状态 --> <!-- 优先显示请求状态 -->
<Badge v-if="record.status === 'pending'" variant="outline" class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground"> <Badge
v-if="record.status === 'pending'"
variant="outline"
class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground"
>
等待中 等待中
</Badge> </Badge>
<Badge v-else-if="record.status === 'streaming'" variant="outline" class="whitespace-nowrap animate-pulse border-primary/50 text-primary"> <Badge
v-else-if="record.status === 'streaming'"
variant="outline"
class="whitespace-nowrap animate-pulse border-primary/50 text-primary"
>
传输中 传输中
</Badge> </Badge>
<Badge v-else-if="record.status === 'failed' || (record.status_code && record.status_code >= 400) || record.error_message" variant="destructive" class="whitespace-nowrap"> <Badge
v-else-if="record.status === 'failed' || (record.status_code && record.status_code >= 400) || record.error_message"
variant="destructive"
class="whitespace-nowrap"
>
失败 失败
</Badge> </Badge>
<Badge v-else-if="record.is_stream" variant="secondary" class="whitespace-nowrap"> <Badge
v-else-if="record.is_stream"
variant="secondary"
class="whitespace-nowrap"
>
流式 流式
</Badge> </Badge>
<Badge v-else variant="outline" class="whitespace-nowrap border-border/60 text-muted-foreground"> <Badge
v-else
variant="outline"
class="whitespace-nowrap border-border/60 text-muted-foreground"
>
标准 标准
</Badge> </Badge>
</TableCell> </TableCell>
@@ -213,7 +347,10 @@
<TableCell class="text-right py-4 w-[100px]"> <TableCell class="text-right py-4 w-[100px]">
<div class="flex flex-col items-end text-xs gap-0.5"> <div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span> <span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span v-if="showActualCost && record.actual_cost !== undefined" class="text-muted-foreground"> <span
v-if="showActualCost && record.actual_cost !== undefined"
class="text-muted-foreground"
>
{{ formatCurrency(record.actual_cost) }} {{ formatCurrency(record.actual_cost) }}
</span> </span>
</div> </div>
@@ -228,7 +365,10 @@
<span v-else-if="record.response_time_ms"> <span v-else-if="record.response_time_ms">
{{ (record.response_time_ms / 1000).toFixed(2) }}s {{ (record.response_time_ms / 1000).toFixed(2) }}s
</span> </span>
<span v-else class="text-muted-foreground">-</span> <span
v-else
class="text-muted-foreground"
>-</span>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
@@ -251,21 +391,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onUnmounted, watch } from 'vue' import { ref, computed, onUnmounted, watch } from 'vue'
import TableCard from '@/components/ui/table-card.vue' import {
import Button from '@/components/ui/button.vue' TableCard,
import Badge from '@/components/ui/badge.vue' Badge,
import Select from '@/components/ui/select.vue' Select,
import SelectTrigger from '@/components/ui/select-trigger.vue' SelectTrigger,
import SelectValue from '@/components/ui/select-value.vue' SelectValue,
import SelectContent from '@/components/ui/select-content.vue' SelectContent,
import SelectItem from '@/components/ui/select-item.vue' SelectItem,
import Table from '@/components/ui/table.vue' Table,
import TableHeader from '@/components/ui/table-header.vue' TableHeader,
import TableBody from '@/components/ui/table-body.vue' TableBody,
import TableRow from '@/components/ui/table-row.vue' TableRow,
import TableHead from '@/components/ui/table-head.vue' TableHead,
import TableCell from '@/components/ui/table-cell.vue' TableCell,
import { Pagination, RefreshButton } from '@/components/ui' Pagination,
RefreshButton,
} from '@/components/ui'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables' import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick' import { useRowClick } from '@/composables/useRowClick'

View File

@@ -8,8 +8,14 @@
<div class="border-b border-border px-6 py-4"> <div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0"> <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<UserPlus v-if="!isEditMode" class="h-5 w-5 text-primary" /> <UserPlus
<SquarePen v-else class="h-5 w-5 text-primary" /> v-if="!isEditMode"
class="h-5 w-5 text-primary"
/>
<SquarePen
v-else
class="h-5 w-5 text-primary"
/>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight"> <h3 class="text-lg font-semibold text-foreground leading-tight">
@@ -23,7 +29,10 @@
</div> </div>
</template> </template>
<form @submit.prevent="handleSubmit" autocomplete="off"> <form
autocomplete="off"
@submit.prevent="handleSubmit"
>
<div class="grid grid-cols-2 gap-0"> <div class="grid grid-cols-2 gap-0">
<!-- 左侧基础设置 --> <!-- 左侧基础设置 -->
<div class="pr-6 space-y-4"> <div class="pr-6 space-y-4">
@@ -32,7 +41,10 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="form-username" class="text-sm font-medium">用户名 <span class="text-muted-foreground">*</span></Label> <Label
for="form-username"
class="text-sm font-medium"
>用户名 <span class="text-muted-foreground">*</span></Label>
<Input <Input
id="form-username" id="form-username"
v-model="form.username" v-model="form.username"
@@ -46,14 +58,15 @@
<div class="space-y-2"> <div class="space-y-2">
<Label class="text-sm font-medium"> <Label class="text-sm font-medium">
{{ isEditMode ? '新密码 (留空保持不变)' : '密码' }} <span v-if="!isEditMode" class="text-muted-foreground">*</span> {{ isEditMode ? '新密码 (留空保持不变)' : '密码' }} <span
v-if="!isEditMode"
class="text-muted-foreground"
>*</span>
</Label> </Label>
<Input <Input
:id="`pwd-${formNonce}`" :id="`pwd-${formNonce}`"
v-model="form.password" v-model="form.password"
:type="passwordFocused ? 'password' : 'text'" :type="passwordFocused ? 'password' : 'text'"
@focus="passwordFocused = true"
@blur="passwordFocused = form.password.length > 0"
autocomplete="new-password" autocomplete="new-password"
data-form-type="other" data-form-type="other"
data-lpignore="true" data-lpignore="true"
@@ -62,12 +75,22 @@
minlength="6" minlength="6"
:placeholder="isEditMode ? '留空保持原密码' : '至少6个字符'" :placeholder="isEditMode ? '留空保持原密码' : '至少6个字符'"
:class="!passwordFocused && form.password.length === 0 ? 'h-10 text-transparent' : 'h-10'" :class="!passwordFocused && form.password.length === 0 ? 'h-10 text-transparent' : 'h-10'"
@focus="passwordFocused = true"
@blur="passwordFocused = form.password.length > 0"
/> />
<p v-if="!isEditMode" class="text-xs text-muted-foreground">密码至少需要6个字符</p> <p
v-if="!isEditMode"
class="text-xs text-muted-foreground"
>
密码至少需要6个字符
</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="form-email" class="text-sm font-medium">邮箱 <span class="text-muted-foreground">*</span></Label> <Label
for="form-email"
class="text-sm font-medium"
>邮箱 <span class="text-muted-foreground">*</span></Label>
<Input <Input
id="form-email" id="form-email"
v-model="form.email" v-model="form.email"
@@ -80,7 +103,10 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="form-quota" class="text-sm font-medium">配额(美元)</Label> <Label
for="form-quota"
class="text-sm font-medium"
>配额(美元)</Label>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<Input <Input
id="form-quota" id="form-quota"
@@ -98,32 +124,55 @@
v-model="form.unlimited" v-model="form.unlimited"
type="checkbox" type="checkbox"
class="h-4 w-4 rounded border-gray-300 cursor-pointer" class="h-4 w-4 rounded border-gray-300 cursor-pointer"
/> >
<Label for="form-unlimited" class="whitespace-nowrap cursor-pointer text-sm">无限制</Label> <Label
for="form-unlimited"
class="whitespace-nowrap cursor-pointer text-sm"
>无限制</Label>
</div> </div>
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="form-role" class="text-sm font-medium">用户角色</Label> <Label
for="form-role"
class="text-sm font-medium"
>用户角色</Label>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Select v-model="form.role" v-model:open="roleSelectOpen" class="flex-1"> <Select
<SelectTrigger id="form-role" class="h-10"> v-model="form.role"
v-model:open="roleSelectOpen"
class="flex-1"
>
<SelectTrigger
id="form-role"
class="h-10"
>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="user">普通用户</SelectItem> <SelectItem value="user">
<SelectItem value="admin">管理员</SelectItem> 普通用户
</SelectItem>
<SelectItem value="admin">
管理员
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<div v-if="!isEditMode" class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24"> <div
v-if="!isEditMode"
class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24"
>
<input <input
id="form-active" id="form-active"
v-model="form.is_active" v-model="form.is_active"
type="checkbox" type="checkbox"
class="h-4 w-4 rounded border-gray-300 cursor-pointer" class="h-4 w-4 rounded border-gray-300 cursor-pointer"
/> >
<Label for="form-active" class="whitespace-nowrap cursor-pointer text-sm">启用用户</Label> <Label
for="form-active"
class="whitespace-nowrap cursor-pointer text-sm"
>启用用户</Label>
</div> </div>
</div> </div>
</div> </div>
@@ -148,9 +197,16 @@
<span :class="form.allowed_providers.length ? 'text-foreground' : 'text-muted-foreground'"> <span :class="form.allowed_providers.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_providers.length ? `已选择 ${form.allowed_providers.length}` : '全部可用' }} {{ form.allowed_providers.length ? `已选择 ${form.allowed_providers.length}` : '全部可用' }}
</span> </span>
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="providerDropdownOpen ? 'rotate-180' : ''" /> <ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="providerDropdownOpen ? 'rotate-180' : ''"
/>
</button> </button>
<div v-if="providerDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="providerDropdownOpen = false"></div> <div
v-if="providerDropdownOpen"
class="fixed inset-0 z-[80]"
@click.stop="providerDropdownOpen = false"
/>
<div <div
v-if="providerDropdownOpen" v-if="providerDropdownOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto" class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
@@ -167,10 +223,13 @@
class="h-4 w-4 rounded border-gray-300 cursor-pointer" class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop @click.stop
@change="toggleSelection('allowed_providers', provider.id)" @change="toggleSelection('allowed_providers', provider.id)"
/> >
<span class="text-sm">{{ provider.display_name || provider.name }}</span> <span class="text-sm">{{ provider.display_name || provider.name }}</span>
</div> </div>
<div v-if="providers.length === 0" class="px-3 py-2 text-sm text-muted-foreground"> <div
v-if="providers.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
暂无可用 Provider 暂无可用 Provider
</div> </div>
</div> </div>
@@ -189,9 +248,16 @@
<span :class="form.allowed_endpoints.length ? 'text-foreground' : 'text-muted-foreground'"> <span :class="form.allowed_endpoints.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_endpoints.length ? `已选择 ${form.allowed_endpoints.length}` : '全部可用' }} {{ form.allowed_endpoints.length ? `已选择 ${form.allowed_endpoints.length}` : '全部可用' }}
</span> </span>
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="endpointDropdownOpen ? 'rotate-180' : ''" /> <ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="endpointDropdownOpen ? 'rotate-180' : ''"
/>
</button> </button>
<div v-if="endpointDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="endpointDropdownOpen = false"></div> <div
v-if="endpointDropdownOpen"
class="fixed inset-0 z-[80]"
@click.stop="endpointDropdownOpen = false"
/>
<div <div
v-if="endpointDropdownOpen" v-if="endpointDropdownOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto" class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
@@ -208,10 +274,13 @@
class="h-4 w-4 rounded border-gray-300 cursor-pointer" class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop @click.stop
@change="toggleSelection('allowed_endpoints', format.value)" @change="toggleSelection('allowed_endpoints', format.value)"
/> >
<span class="text-sm">{{ format.label }}</span> <span class="text-sm">{{ format.label }}</span>
</div> </div>
<div v-if="apiFormats.length === 0" class="px-3 py-2 text-sm text-muted-foreground"> <div
v-if="apiFormats.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
暂无可用 API 格式 暂无可用 API 格式
</div> </div>
</div> </div>
@@ -230,9 +299,16 @@
<span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'"> <span :class="form.allowed_models.length ? 'text-foreground' : 'text-muted-foreground'">
{{ form.allowed_models.length ? `已选择 ${form.allowed_models.length}` : '全部可用' }} {{ form.allowed_models.length ? `已选择 ${form.allowed_models.length}` : '全部可用' }}
</span> </span>
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="modelDropdownOpen ? 'rotate-180' : ''" /> <ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="modelDropdownOpen ? 'rotate-180' : ''"
/>
</button> </button>
<div v-if="modelDropdownOpen" class="fixed inset-0 z-[80]" @click.stop="modelDropdownOpen = false"></div> <div
v-if="modelDropdownOpen"
class="fixed inset-0 z-[80]"
@click.stop="modelDropdownOpen = false"
/>
<div <div
v-if="modelDropdownOpen" v-if="modelDropdownOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto" class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
@@ -249,10 +325,13 @@
class="h-4 w-4 rounded border-gray-300 cursor-pointer" class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop @click.stop
@change="toggleSelection('allowed_models', model.name)" @change="toggleSelection('allowed_models', model.name)"
/> >
<span class="text-sm">{{ model.name }}</span> <span class="text-sm">{{ model.name }}</span>
</div> </div>
<div v-if="globalModels.length === 0" class="px-3 py-2 text-sm text-muted-foreground"> <div
v-if="globalModels.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
暂无可用模型 暂无可用模型
</div> </div>
</div> </div>
@@ -263,8 +342,19 @@
</form> </form>
<template #footer> <template #footer>
<Button variant="outline" @click="handleCancel" type="button" class="h-10 px-5">取消</Button> <Button
<Button @click="handleSubmit" class="h-10 px-5" :disabled="saving || !isFormValid"> variant="outline"
type="button"
class="h-10 px-5"
@click="handleCancel"
>
取消
</Button>
<Button
class="h-10 px-5"
:disabled="saving || !isFormValid"
@click="handleSubmit"
>
{{ saving ? '处理中...' : (isEditMode ? '更新' : '创建') }} {{ saving ? '处理中...' : (isEditMode ? '更新' : '创建') }}
</Button> </Button>
</template> </template>