Initial commit

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

View File

@@ -0,0 +1,337 @@
<template>
<Dialog
:model-value="open"
@update:model-value="handleDialogUpdate"
:title="dialogTitle"
:description="dialogDescription"
:icon="dialogIcon"
size="md"
>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- 模式选择仅创建时显示 -->
<div v-if="!isEditMode" class="space-y-2">
<Label>创建类型 *</Label>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
@click="form.mapping_type = 'alias'"
:class="[
'p-3 rounded-lg border-2 text-left transition-all',
form.mapping_type === 'alias'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
]"
>
<div class="font-medium text-sm">别名</div>
<div class="text-xs text-muted-foreground mt-1">名称简写按目标模型计费</div>
</button>
<button
type="button"
@click="form.mapping_type = 'mapping'"
:class="[
'p-3 rounded-lg border-2 text-left transition-all',
form.mapping_type === 'mapping'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
]"
>
<div class="font-medium text-sm">映射</div>
<div class="text-xs text-muted-foreground mt-1">模型降级按源模型计费</div>
</button>
</div>
</div>
<!-- 模式说明 -->
<div class="rounded-lg border border-border bg-muted/50 p-3 text-sm">
<p class="text-foreground font-medium mb-1">
{{ form.mapping_type === 'alias' ? '别名模式' : '映射模式' }}
</p>
<p class="text-muted-foreground text-xs">
{{ form.mapping_type === 'alias'
? '用户请求此别名时,会路由到目标模型,并按目标模型价格计费。'
: '将源模型的请求转发到目标模型处理,按源模型价格计费。' }}
</p>
</div>
<!-- Provider 选择/作用范围 -->
<div v-if="showProviderSelect" class="space-y-2">
<Label>作用范围</Label>
<!-- 固定 Provider 时显示只读 -->
<div v-if="fixedProvider" class="px-3 py-2 border rounded-md bg-muted/50 text-sm">
{{ fixedProvider.display_name || fixedProvider.name }}
</div>
<!-- 否则显示可选择的下拉 -->
<Select v-else v-model:open="providerSelectOpen" :model-value="form.provider_id || 'global'" @update:model-value="handleProviderChange">
<SelectTrigger class="w-full">
<SelectValue placeholder="选择作用范围" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">全局所有 Provider</SelectItem>
<SelectItem v-for="p in providers" :key="p.id" :value="p.id">
{{ p.display_name || p.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 别名模式别名名称 -->
<div v-if="form.mapping_type === 'alias'" class="space-y-2">
<Label for="alias-name">别名名称 *</Label>
<Input
id="alias-name"
v-model="form.alias"
placeholder="如sonnet, opus"
:disabled="isEditMode"
required
/>
<p class="text-xs text-muted-foreground">
{{ isEditMode ? '创建后不可修改' : '用户将使用此名称请求模型' }}
</p>
</div>
<!-- 映射模式选择源模型 -->
<div v-else class="space-y-2">
<Label>源模型 (用户请求的模型) *</Label>
<Select v-model:open="sourceModelSelectOpen" :model-value="form.alias" @update:model-value="form.alias = $event" :disabled="isEditMode">
<SelectTrigger class="w-full" :class="{ 'opacity-50': isEditMode }">
<SelectValue placeholder="请选择源模型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="model in availableSourceModels"
:key="model.id"
:value="model.name"
>
{{ model.display_name }} ({{ model.name }})
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ isEditMode ? '创建后不可修改' : '选择要被映射的源模型,计费将按此模型价格' }}
</p>
</div>
<!-- 目标模型选择 -->
<div class="space-y-2">
<Label>
{{ form.mapping_type === 'alias' ? '目标模型 *' : '目标模型 (实际处理请求) *' }}
</Label>
<!-- 固定目标模型时显示只读信息 -->
<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="text-muted-foreground ml-1">({{ fixedTargetModel.name }})</span>
</div>
<!-- 否则显示下拉选择 -->
<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">
<SelectValue placeholder="请选择模型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="model in availableTargetModels"
:key="model.id"
:value="model.id"
>
{{ model.display_name }} ({{ model.name }})
</SelectItem>
</SelectContent>
</Select>
</div>
</form>
<template #footer>
<Button type="button" variant="outline" @click="handleCancel">
取消
</Button>
<Button @click="handleSubmit" :disabled="submitting">
<Loader2 v-if="submitting" class="w-4 h-4 mr-2 animate-spin" />
{{ isEditMode ? '保存' : '创建' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Loader2, Tag, SquarePen } from 'lucide-vue-next'
import { Dialog, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import type { ModelAlias, CreateModelAliasRequest, UpdateModelAliasRequest } from '@/api/endpoints/aliases'
import type { GlobalModelResponse } from '@/api/global-models'
export interface ProviderOption {
id: string
name: string
display_name?: string
}
interface AliasFormData {
alias: string
global_model_id: string
provider_id: string | null
mapping_type: 'alias' | 'mapping'
is_active: boolean
}
const props = withDefaults(defineProps<{
open: boolean
editingAlias?: ModelAlias | null
globalModels: GlobalModelResponse[]
providers?: ProviderOption[]
fixedTargetModel?: GlobalModelResponse | null // 用于从模型详情抽屉打开时固定目标模型
fixedProvider?: ProviderOption | null // 用于 Provider 特定别名固定 Provider
showProviderSelect?: boolean // 是否显示 Provider 选择(默认 true
}>(), {
editingAlias: null,
providers: () => [],
fixedTargetModel: null,
fixedProvider: null,
showProviderSelect: true
})
const emit = defineEmits<{
'update:open': [value: boolean]
'submit': [data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean]
}>()
const { success, error: showError } = useToast()
// 状态
const submitting = ref(false)
const providerSelectOpen = ref(false)
const sourceModelSelectOpen = ref(false)
const targetModelSelectOpen = ref(false)
const form = ref<AliasFormData>({
alias: '',
global_model_id: '',
provider_id: null,
mapping_type: 'alias',
is_active: true,
})
// 处理 Provider 选择变化
function handleProviderChange(value: string) {
form.value.provider_id = value === 'global' ? null : value
}
// 重置表单
function resetForm() {
form.value = {
alias: '',
global_model_id: props.fixedTargetModel?.id || '',
provider_id: props.fixedProvider?.id || null,
mapping_type: 'alias',
is_active: true,
}
}
// 加载别名数据(编辑模式)
function loadAliasData() {
if (!props.editingAlias) return
form.value = {
alias: props.editingAlias.alias,
global_model_id: props.editingAlias.global_model_id,
provider_id: props.editingAlias.provider_id,
mapping_type: props.editingAlias.mapping_type || 'alias',
is_active: props.editingAlias.is_active,
}
}
// 使用 useFormDialog 统一处理对话框逻辑
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
isOpen: () => props.open,
entity: () => props.editingAlias,
isLoading: submitting,
onClose: () => emit('update:open', false),
loadData: loadAliasData,
resetForm,
})
// 对话框标题
const dialogTitle = computed(() => {
if (isEditMode.value) {
return form.value.mapping_type === 'mapping' ? '编辑映射' : '编辑别名'
}
if (props.fixedProvider) {
return `创建 ${props.fixedProvider.display_name || props.fixedProvider.name} 特定别名/映射`
}
return '创建别名/映射'
})
// 对话框描述
const dialogDescription = computed(() => {
if (isEditMode.value) {
return form.value.mapping_type === 'mapping' ? '修改模型映射配置' : '修改别名设置'
}
return '为模型创建别名或映射规则'
})
// 对话框图标
const dialogIcon = computed(() => isEditMode.value ? SquarePen : Tag)
// 作用范围描述
const scopeDescription = computed(() => {
if (props.fixedProvider) {
return `仅对 ${props.fixedProvider.display_name || props.fixedProvider.name} 生效。`
}
if (form.value.provider_id) {
const provider = props.providers.find(p => p.id === form.value.provider_id)
if (provider) {
return `仅对 ${provider.display_name || provider.name} 生效。`
}
}
return '全局生效。'
})
// 映射模式下可选的源模型(排除已选择的目标模型)
const availableSourceModels = computed(() => {
return props.globalModels.filter(m => m.id !== form.value.global_model_id)
})
// 可选的目标模型(映射模式下排除已选择的源模型)
const availableTargetModels = computed(() => {
if (form.value.mapping_type === 'mapping' && form.value.alias) {
// 找到源模型对应的 GlobalModel
const sourceModel = props.globalModels.find(m => m.name === form.value.alias)
if (sourceModel) {
return props.globalModels.filter(m => m.id !== sourceModel.id)
}
}
return props.globalModels
})
// 提交表单
async function handleSubmit() {
if (!form.value.alias) {
showError(form.value.mapping_type === 'alias' ? '请输入别名名称' : '请选择源模型', '错误')
return
}
const targetModelId = props.fixedTargetModel?.id || form.value.global_model_id
if (!targetModelId) {
showError('请选择目标模型', '错误')
return
}
submitting.value = true
try {
const data: CreateModelAliasRequest | UpdateModelAliasRequest = {
alias: form.value.alias,
global_model_id: targetModelId,
provider_id: props.fixedProvider?.id || form.value.provider_id,
mapping_type: form.value.mapping_type,
is_active: form.value.is_active,
}
emit('submit', data, !!props.editingAlias)
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,323 @@
<template>
<Dialog
:model-value="open"
:title="isEditMode ? '编辑模型' : '创建统一模型'"
:description="isEditMode ? '修改模型配置和价格信息' : '添加一个新的全局模型定义'"
:icon="isEditMode ? SquarePen : Layers"
size="xl"
@update:model-value="handleDialogUpdate"
>
<form @submit.prevent="handleSubmit" class="space-y-5 max-h-[70vh] overflow-y-auto pr-1">
<!-- 基本信息 -->
<section class="space-y-3">
<h4 class="font-medium text-sm">基本信息</h4>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label for="model-name" class="text-xs">模型名称 *</Label>
<Input
id="model-name"
v-model="form.name"
placeholder="claude-3-5-sonnet-20241022"
:disabled="isEditMode"
required
/>
<p v-if="!isEditMode" class="text-xs text-muted-foreground">创建后不可修改</p>
</div>
<div class="space-y-1.5">
<Label for="model-display-name" class="text-xs">显示名称 *</Label>
<Input
id="model-display-name"
v-model="form.display_name"
placeholder="Claude 3.5 Sonnet"
required
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="model-description" class="text-xs">描述</Label>
<Input
id="model-description"
v-model="form.description"
placeholder="简短描述此模型的特点"
/>
</div>
</section>
<!-- 能力配置 -->
<section class="space-y-2">
<h4 class="font-medium text-sm">默认能力</h4>
<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">
<input type="checkbox" v-model="form.default_supports_streaming" class="rounded" />
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
<span>流式输出</span>
</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">
<input type="checkbox" v-model="form.default_supports_vision" class="rounded" />
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
<span>视觉理解</span>
</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">
<input type="checkbox" v-model="form.default_supports_function_calling" class="rounded" />
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
<span>工具调用</span>
</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">
<input type="checkbox" v-model="form.default_supports_extended_thinking" class="rounded" />
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
<span>深度思考</span>
</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">
<input type="checkbox" v-model="form.default_supports_image_generation" class="rounded" />
<Image class="w-3.5 h-3.5 text-muted-foreground" />
<span>图像生成</span>
</label>
</div>
</section>
<!-- Key 能力配置 -->
<section v-if="availableCapabilities.length > 0" class="space-y-2">
<h4 class="font-medium text-sm">Key 能力支持</h4>
<div class="flex flex-wrap gap-2">
<label
v-for="cap in availableCapabilities"
:key="cap.name"
class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"
>
<input
type="checkbox"
:checked="form.supported_capabilities?.includes(cap.name)"
@change="toggleCapability(cap.name)"
class="rounded"
/>
<span>{{ cap.display_name }}</span>
</label>
</div>
</section>
<!-- 价格配置 -->
<section class="space-y-3">
<h4 class="font-medium text-sm">价格配置</h4>
<TieredPricingEditor v-model="tieredPricing" :show-cache1h="form.supported_capabilities?.includes('cache_1h')" />
<!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费 ($/)</Label>
<Input
:model-value="form.default_price_per_request ?? ''"
type="number"
step="0.001"
min="0"
class="w-32"
placeholder="留空不启用"
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
/>
<span class="text-xs text-muted-foreground">每次请求固定费用,可与 Token 计费叠加</span>
</div>
</section>
</form>
<template #footer>
<Button type="button" variant="outline" @click="handleCancel">
取消
</Button>
<Button @click="handleSubmit" :disabled="submitting">
<Loader2 v-if="submitting" class="w-4 h-4 mr-2 animate-spin" />
{{ isEditMode ? '保存' : '创建' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen } from 'lucide-vue-next'
import { Dialog, Button, Input, Label } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseNumberInput } from '@/utils/form'
import TieredPricingEditor from './TieredPricingEditor.vue'
import {
createGlobalModel,
updateGlobalModel,
type GlobalModelResponse,
type GlobalModelCreate,
type GlobalModelUpdate,
} from '@/api/global-models'
import type { TieredPricingConfig } from '@/api/endpoints/types'
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
const props = defineProps<{
open: boolean
model?: GlobalModelResponse | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'success': []
}>()
const { success, error: showError } = useToast()
const submitting = ref(false)
// 阶梯计费配置(统一使用,固定价格就是单阶梯)
const tieredPricing = ref<TieredPricingConfig | null>(null)
interface FormData {
name: string
display_name: string
description?: string
default_price_per_request?: number
default_supports_streaming?: boolean
default_supports_image_generation?: boolean
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_extended_thinking?: boolean
supported_capabilities?: string[]
is_active?: boolean
}
const defaultForm = (): FormData => ({
name: '',
display_name: '',
description: '',
default_price_per_request: undefined,
default_supports_streaming: true,
default_supports_image_generation: false,
default_supports_vision: false,
default_supports_function_calling: false,
default_supports_extended_thinking: false,
supported_capabilities: [],
is_active: true,
})
const form = ref<FormData>(defaultForm())
// Key 能力选项
const availableCapabilities = ref<CapabilityDefinition[]>([])
// 加载可用能力列表
async function loadCapabilities() {
try {
availableCapabilities.value = await getAllCapabilities()
} catch (err) {
console.error('Failed to load capabilities:', err)
}
}
// 切换能力
function toggleCapability(capName: string) {
if (!form.value.supported_capabilities) {
form.value.supported_capabilities = []
}
const index = form.value.supported_capabilities.indexOf(capName)
if (index >= 0) {
form.value.supported_capabilities.splice(index, 1)
} else {
form.value.supported_capabilities.push(capName)
}
}
// 组件挂载时加载能力列表
onMounted(() => {
loadCapabilities()
})
// 重置表单
function resetForm() {
form.value = defaultForm()
tieredPricing.value = null
}
// 加载模型数据(编辑模式)
function loadModelData() {
if (!props.model) return
form.value = {
name: props.model.name,
display_name: props.model.display_name,
description: props.model.description,
default_price_per_request: props.model.default_price_per_request,
default_supports_streaming: props.model.default_supports_streaming,
default_supports_image_generation: props.model.default_supports_image_generation,
default_supports_vision: props.model.default_supports_vision,
default_supports_function_calling: props.model.default_supports_function_calling,
default_supports_extended_thinking: props.model.default_supports_extended_thinking,
supported_capabilities: [...(props.model.supported_capabilities || [])],
is_active: props.model.is_active,
}
// 加载阶梯计费配置(深拷贝)
if (props.model.default_tiered_pricing) {
tieredPricing.value = JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
}
}
// 使用 useFormDialog 统一处理对话框逻辑
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
isOpen: () => props.open,
entity: () => props.model,
isLoading: submitting,
onClose: () => emit('update:open', false),
loadData: loadModelData,
resetForm,
})
async function handleSubmit() {
if (!form.value.name || !form.value.display_name) {
showError('请填写模型名称和显示名称')
return
}
if (!tieredPricing.value?.tiers?.length) {
showError('请配置至少一个价格阶梯')
return
}
submitting.value = true
try {
if (isEditMode.value && props.model) {
const updateData: GlobalModelUpdate = {
display_name: form.value.display_name,
description: form.value.description,
// 使用 null 而不是 undefined 来显式清空字段
default_price_per_request: form.value.default_price_per_request ?? null,
default_tiered_pricing: tieredPricing.value,
default_supports_streaming: form.value.default_supports_streaming,
default_supports_image_generation: form.value.default_supports_image_generation,
default_supports_vision: form.value.default_supports_vision,
default_supports_function_calling: form.value.default_supports_function_calling,
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : null,
is_active: form.value.is_active,
}
await updateGlobalModel(props.model.id, updateData)
success('模型更新成功')
} else {
const createData: GlobalModelCreate = {
name: form.value.name!,
display_name: form.value.display_name!,
description: form.value.description,
default_price_per_request: form.value.default_price_per_request || undefined,
default_tiered_pricing: tieredPricing.value,
default_supports_streaming: form.value.default_supports_streaming,
default_supports_image_generation: form.value.default_supports_image_generation,
default_supports_vision: form.value.default_supports_vision,
default_supports_function_calling: form.value.default_supports_function_calling,
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : undefined,
is_active: form.value.is_active,
}
await createGlobalModel(createData)
success('模型创建成功')
}
emit('update:open', false)
emit('success')
} catch (err: any) {
showError(err.response?.data?.detail || err.message, isEditMode.value ? '更新失败' : '创建失败')
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,784 @@
<template>
<!-- 模型详情抽屉 -->
<Teleport to="body">
<Transition name="drawer">
<div
v-if="open && model"
class="fixed inset-0 z-50 flex justify-end"
@click.self="handleBackdropClick"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="handleBackdropClick"></div>
<!-- 抽屉内容 -->
<Card class="relative h-full w-[700px] rounded-none shadow-2xl overflow-y-auto">
<div class="sticky top-0 z-10 bg-background border-b p-6">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1 flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="text-xl font-bold truncate">{{ model.display_name }}</h3>
<Badge :variant="model.is_active ? 'default' : 'secondary'" class="text-xs shrink-0">
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground font-mono">{{ model.name }}</span>
<button
class="p-0.5 rounded hover:bg-muted transition-colors"
title="复制模型 ID"
@click="copyToClipboard(model.name)"
>
<Copy class="w-3 h-3 text-muted-foreground" />
</button>
</div>
<p v-if="model.description" class="text-xs text-muted-foreground">
{{ model.description }}
</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" @click="$emit('edit-model', model)" title="编辑模型">
<Edit class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
@click="$emit('toggle-model-status', model)"
:title="model.is_active ? '点击停用' : '点击启用'"
>
<Power class="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" @click="handleClose" title="关闭">
<X class="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div class="p-6">
<!-- 自定义 Tab 切换 -->
<div class="flex gap-1 p-1 bg-muted/40 rounded-lg mb-4">
<button
type="button"
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
detailTab === 'basic'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="detailTab = 'basic'"
>
基本信息
</button>
<button
type="button"
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
detailTab === 'providers'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="detailTab = 'providers'"
>
关联提供商
</button>
<button
type="button"
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
detailTab === 'aliases'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="detailTab = 'aliases'"
>
别名/映射
</button>
</div>
<!-- Tab 内容 -->
<div v-show="detailTab === 'basic'" class="space-y-6">
<!-- 基础属性 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm">基础属性</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-xs text-muted-foreground">创建时间</Label>
<p class="text-sm mt-1">{{ formatDate(model.created_at) }}</p>
</div>
</div>
</div>
<!-- 模型能力 -->
<div class="space-y-3">
<h4 class="font-semibold text-sm">模型能力</h4>
<div class="grid grid-cols-2 gap-3">
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Zap class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">Streaming</p>
<p class="text-xs text-muted-foreground">流式输出</p>
</div>
<Badge :variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'" class="text-xs">
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Image class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">Image Generation</p>
<p class="text-xs text-muted-foreground">图像生成</p>
</div>
<Badge :variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'" class="text-xs">
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Eye class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">Vision</p>
<p class="text-xs text-muted-foreground">视觉理解</p>
</div>
<Badge :variant="model.default_supports_vision ?? false ? 'default' : 'secondary'" class="text-xs">
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Wrench class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">Tool Use</p>
<p class="text-xs text-muted-foreground">工具调用</p>
</div>
<Badge :variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'" class="text-xs">
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
<Brain class="w-5 h-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium">Extended Thinking</p>
<p class="text-xs text-muted-foreground">深度思考</p>
</div>
<Badge :variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'" class="text-xs">
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
</Badge>
</div>
</div>
</div>
<!-- 模型偏好 -->
<div 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">
<Badge
v-for="cap in model.supported_capabilities"
:key="cap"
variant="outline"
class="text-xs"
>
{{ getCapabilityDisplayName(cap) }}
</Badge>
</div>
</div>
<!-- 默认定价 -->
<div class="space-y-3">
<h4 class="font-semibold text-sm">默认定价</h4>
<!-- 单阶梯固定价格展示 -->
<div v-if="getTierCount(model.default_tiered_pricing) <= 1" class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<!-- Token 计费 -->
<div class="p-3 rounded-lg border">
<Label class="text-xs text-muted-foreground">输入价格 ($/M)</Label>
<p class="text-lg font-semibold mt-1">
{{ getFirstTierPrice(model.default_tiered_pricing, 'input_price_per_1m') }}
</p>
</div>
<div class="p-3 rounded-lg border">
<Label class="text-xs text-muted-foreground">输出价格 ($/M)</Label>
<p class="text-lg font-semibold mt-1">
{{ getFirstTierPrice(model.default_tiered_pricing, 'output_price_per_1m') }}
</p>
</div>
<div class="p-3 rounded-lg border">
<Label class="text-xs text-muted-foreground">缓存创建 ($/M)</Label>
<p class="text-sm font-mono mt-1">
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_creation_price_per_1m') }}
</p>
</div>
<div class="p-3 rounded-lg border">
<Label class="text-xs text-muted-foreground">缓存读取 ($/M)</Label>
<p class="text-sm font-mono mt-1">
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_read_price_per_1m') }}
</p>
</div>
</div>
<!-- 1h 缓存 -->
<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>
<span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span>
</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">
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/</span>
</div>
</div>
<!-- 多阶梯计费展示 -->
<div v-else class="space-y-3">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Layers class="w-4 h-4" />
<span>阶梯计费 ({{ getTierCount(model.default_tiered_pricing) }} )</span>
</div>
<!-- 阶梯价格表格 -->
<div class="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="text-xs h-9">阶梯</TableHead>
<TableHead class="text-xs h-9 text-right">输入 ($/M)</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>
</TableHeader>
<TableBody>
<TableRow
v-for="(tier, index) in model.default_tiered_pricing?.tiers || []"
:key="index"
class="text-xs"
>
<TableCell class="py-2">
<span v-if="tier.up_to === null" class="text-muted-foreground">
{{ index === 0 ? '所有' : `> ${formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to)}` }}
</span>
<span v-else>
{{ index === 0 ? '0' : formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to) }} - {{ formatTierLimit(tier.up_to) }}
</span>
</TableCell>
<TableCell class="py-2 text-right font-mono">
${{ tier.input_price_per_1m?.toFixed(2) || '0.00' }}
</TableCell>
<TableCell class="py-2 text-right font-mono">
${{ tier.output_price_per_1m?.toFixed(2) || '0.00' }}
</TableCell>
<TableCell class="py-2 text-right font-mono text-muted-foreground">
{{ tier.cache_creation_price_per_1m != null ? `$${tier.cache_creation_price_per_1m.toFixed(2)}` : '-' }}
</TableCell>
<TableCell class="py-2 text-right font-mono text-muted-foreground">
{{ tier.cache_read_price_per_1m != null ? `$${tier.cache_read_price_per_1m.toFixed(2)}` : '-' }}
</TableCell>
<TableCell class="py-2 text-right font-mono text-muted-foreground">
{{ get1hCachePrice(tier) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</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">
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/次</span>
</div>
</div>
</div>
<!-- 统计信息 -->
<div class="space-y-3">
<h4 class="font-semibold text-sm">统计信息</h4>
<div class="grid grid-cols-2 gap-3">
<div class="p-3 rounded-lg border bg-muted/20">
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">关联提供商</Label>
<Building2 class="w-4 h-4 text-muted-foreground" />
</div>
<p class="text-2xl font-bold mt-1">{{ model.provider_count || 0 }}</p>
</div>
<div class="p-3 rounded-lg border bg-muted/20">
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">别名数量</Label>
<Tag class="w-4 h-4 text-muted-foreground" />
</div>
<p class="text-2xl font-bold mt-1">{{ model.alias_count || 0 }}</p>
</div>
</div>
</div>
</div>
<!-- Tab 2: 关联提供商 -->
<div v-show="detailTab === 'providers'">
<Card class="overflow-hidden">
<!-- 标题栏 -->
<div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div>
<h4 class="text-sm font-semibold">关联提供商列表</h4>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="$emit('add-provider')"
title="添加关联"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="$emit('refresh-providers')"
title="刷新"
>
<RefreshCw class="w-3.5 h-3.5" :class="loadingProviders ? 'animate-spin' : ''" />
</Button>
</div>
</div>
</div>
<!-- 表格内容 -->
<div v-if="loadingProviders" class="flex items-center justify-center py-12">
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<Table v-else-if="providers.length > 0">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-10 font-semibold">Provider</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>
</TableHeader>
<TableBody>
<TableRow
v-for="provider in providers"
:key="provider.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<TableCell class="py-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="provider.is_active ? '活跃' : '停用'"
></span>
<span class="font-medium truncate">{{ provider.display_name }}</span>
</div>
</TableCell>
<TableCell class="py-3">
<div class="flex gap-0.5">
<Zap v-if="provider.supports_streaming" 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>
</TableCell>
<TableCell class="py-3">
<div class="text-xs font-mono space-y-0.5">
<!-- Token 计费:输入/输出 -->
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
<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 v-if="(provider.tier_count || 1) > 1" class="ml-1 text-muted-foreground" title="阶梯计费">[阶梯]</span>
</div>
<!-- 缓存价格 -->
<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 class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 1h 缓存价格 -->
<div v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0" class="text-muted-foreground">
<span>1h 缓存:</span>
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 按次计费 -->
<div v-if="(provider.price_per_request || 0) > 0">
<span class="text-muted-foreground">按次:</span>
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/次</span>
</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>
</div>
</TableCell>
<TableCell class="py-3 text-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('edit-provider', provider)"
title="编辑此关联"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('toggle-provider-status', provider)"
:title="provider.is_active ? '停用此关联' : '启用此关联'"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('delete-provider', provider)"
title="删除此关联"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 空状态 -->
<div v-else class="text-center py-12">
<Building2 class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground">暂无关联提供商</p>
<Button size="sm" variant="outline" class="mt-4" @click="$emit('add-provider')">
<Plus class="w-4 h-4 mr-1" />
添加第一个关联
</Button>
</div>
</Card>
</div>
<!-- Tab 3: 别名 -->
<div v-show="detailTab === 'aliases'">
<Card class="overflow-hidden">
<!-- 标题栏 -->
<div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div>
<h4 class="text-sm font-semibold">别名与映射</h4>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="$emit('add-alias')"
title="添加别名/映射"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="$emit('refresh-aliases')"
title="刷新"
>
<RefreshCw class="w-3.5 h-3.5" :class="loadingAliases ? 'animate-spin' : ''" />
</Button>
</div>
</div>
</div>
<!-- 表格内容 -->
<div v-if="loadingAliases" class="flex items-center justify-center py-12">
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<Table v-else-if="aliases.length > 0">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-10 font-semibold">别名</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>
</TableHeader>
<TableBody>
<TableRow
v-for="alias in aliases"
:key="alias.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<TableCell class="py-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="alias.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="alias.is_active ? '活跃' : '停用'"
></span>
<code class="text-sm font-medium bg-muted px-1.5 py-0.5 rounded">{{ alias.alias }}</code>
</div>
</TableCell>
<TableCell class="py-3">
<Badge variant="secondary" class="text-xs">
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
</Badge>
</TableCell>
<TableCell class="py-3">
<Badge
v-if="alias.provider_id"
variant="outline"
class="text-xs truncate max-w-[90px]"
:title="alias.provider_name || 'Provider'"
>
{{ alias.provider_name || 'Provider' }}
</Badge>
<Badge v-else variant="default" class="text-xs">全局</Badge>
</TableCell>
<TableCell class="py-3 text-center">
<div class="flex items-center justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('edit-alias', alias)"
title="编辑"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('toggle-alias-status', alias)"
:title="alias.is_active ? '停用' : '启用'"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('delete-alias', alias)"
title="删除"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 空状态 -->
<div v-else class="text-center py-12">
<Tag class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground">暂无别名或映射</p>
<Button size="sm" variant="outline" class="mt-4" @click="$emit('add-alias')">
<Plus class="w-4 h-4 mr-1" />
添加别名/映射
</Button>
</div>
</Card>
</div>
</div>
</Card>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import {
X,
Eye,
Wrench,
Brain,
Zap,
Image,
Building2,
Tag,
Plus,
Edit,
Trash2,
Power,
Loader2,
RefreshCw,
Copy,
Layers
} from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
const { success: showSuccess, error: showError } = useToast()
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import Label from '@/components/ui/label.vue'
import Table from '@/components/ui/table.vue'
import TableHeader from '@/components/ui/table-header.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
// 使用外部类型定义
import type { GlobalModelResponse } from '@/api/global-models'
import type { ModelAlias } from '@/api/endpoints/aliases'
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
import type { CapabilityDefinition } from '@/api/endpoints'
interface Props {
model: GlobalModelResponse | null
open: boolean
providers: any[]
aliases: ModelAlias[]
loadingProviders?: boolean
loadingAliases?: boolean
hasBlockingDialogOpen?: boolean
capabilities?: CapabilityDefinition[]
}
const props = withDefaults(defineProps<Props>(), {
loadingProviders: false,
loadingAliases: false,
hasBlockingDialogOpen: false
})
// 根据能力名称获取显示名称
function getCapabilityDisplayName(capName: string): string {
const cap = props.capabilities?.find(c => c.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')
// 处理背景点击
function handleBackdropClick() {
if (!props.hasBlockingDialogOpen) {
handleClose()
}
}
// 关闭抽屉
function handleClose() {
if (!props.hasBlockingDialogOpen) {
emit('update:open', false)
}
}
// 复制到剪贴板
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
showSuccess('已复制')
} catch {
showError('复制失败')
}
}
// 格式化日期
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
// 从 tiered_pricing 获取第一阶梯的价格
function getFirstTierPrice(
tieredPricing: TieredPricingConfig | undefined | null,
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
): string {
if (!tieredPricing?.tiers?.length) return '-'
const firstTier = tieredPricing.tiers[0]
const value = firstTier[priceKey]
if (value == null || value === 0) return '-'
return `$${value.toFixed(2)}`
}
// 获取阶梯数量
function getTierCount(tieredPricing: TieredPricingConfig | undefined | null): number {
return tieredPricing?.tiers?.length || 0
}
// 格式化阶梯上限tokens 数量简化显示)
function formatTierLimit(limit: number | null | undefined): string {
if (limit == null) return ''
if (limit >= 1000000) {
return `${(limit / 1000000).toFixed(1)}M`
} else if (limit >= 1000) {
return `${(limit / 1000).toFixed(0)}K`
}
return limit.toString()
}
// 获取 1h 缓存价格
function get1hCachePrice(tier: PricingTier): string {
const ttl1h = tier.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
if (ttl1h) {
return `$${ttl1h.cache_creation_price_per_1m.toFixed(2)}`
}
return '-'
}
// 获取第一阶梯的 1h 缓存价格
function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | null): string {
if (!tieredPricing?.tiers?.length) return '-'
return get1hCachePrice(tieredPricing.tiers[0])
}
// 监听 open 变化,重置 tab
watch(() => props.open, (newOpen) => {
if (newOpen) {
// 直接设置为 basic不需要先重置为空
detailTab.value = 'basic'
}
})
</script>
<style scoped>
/* 抽屉过渡动画 */
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.3s ease;
}
.drawer-enter-active .relative,
.drawer-leave-active .relative {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from .relative {
transform: translateX(100%);
}
.drawer-leave-to .relative {
transform: translateX(100%);
}
.drawer-enter-to .relative,
.drawer-leave-from .relative {
transform: translateX(0);
}
</style>

View File

@@ -0,0 +1,531 @@
<template>
<div class="space-y-3">
<!-- 阶梯列表 -->
<div
v-for="(tier, index) in localTiers"
:key="index"
class="p-3 border rounded-lg bg-muted/20 space-y-3"
>
<!-- 阶梯头部 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
<span class="text-muted-foreground">-</span>
<template v-if="index < localTiers.length - 1">
<template v-if="customInputMode[index]">
<Input
v-model="customInputValue[index]"
type="number"
min="1"
class="h-7 w-20 text-sm"
placeholder="K"
@keyup.enter="confirmCustomInput(index)"
@blur="confirmCustomInput(index)"
/>
<span class="text-xs text-muted-foreground">K</span>
</template>
<select
v-else
:value="getSelectValue(index)"
class="h-7 px-2 text-sm border rounded bg-background"
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
>
<option
v-for="opt in getAvailableThresholds(index)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</template>
<span v-else class="font-medium">无上限</span>
</div>
<Button
v-if="localTiers.length > 1"
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
@click="removeTier(index)"
>
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<!-- 价格输入 -->
<div :class="['grid gap-3', showCache1h ? 'grid-cols-5' : 'grid-cols-4']">
<div class="space-y-1">
<Label class="text-xs">输入 ($/M)</Label>
<Input
:model-value="tier.input_price_per_1m"
type="number"
step="0.01"
min="0"
class="h-8"
placeholder="0"
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs">输出 ($/M)</Label>
<Input
:model-value="tier.output_price_per_1m"
type="number"
step="0.01"
min="0"
class="h-8"
placeholder="0"
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
/>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">缓存创建</Label>
<Input
:model-value="getCacheCreationDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:placeholder="getCacheCreationPlaceholder(index)"
@update:model-value="(v) => updateCacheCreation(index, v)"
/>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">缓存读取</Label>
<Input
:model-value="getCacheReadDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:placeholder="getCacheReadPlaceholder(index)"
@update:model-value="(v) => updateCacheRead(index, v)"
/>
</div>
<div v-if="showCache1h" class="space-y-1">
<Label class="text-xs text-muted-foreground">1h 缓存创建</Label>
<Input
:model-value="getCache1hDisplay(index)"
type="number"
step="0.01"
min="0"
class="h-8"
:placeholder="getCache1hPlaceholder(index)"
@update:model-value="(v) => updateCache1h(index, v)"
/>
</div>
</div>
</div>
<!-- 添加阶梯按钮 -->
<Button
variant="outline"
size="sm"
class="w-full"
@click="addTier"
>
<Plus class="w-4 h-4 mr-2" />
添加价格阶梯
</Button>
<!-- 验证提示 -->
<p v-if="validationError" class="text-xs text-destructive">
{{ validationError }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue'
import { Plus, X } from 'lucide-vue-next'
import { Button, Input, Label } from '@/components/ui'
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
const props = defineProps<{
modelValue?: TieredPricingConfig | null
showCache1h?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: TieredPricingConfig | null]
}>()
// 本地状态
const localTiers = ref<PricingTier[]>([])
// 跟踪每个阶梯的缓存价格是否被手动设置
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
// 预设的阶梯上限选项
const THRESHOLD_OPTIONS = [
{ value: 64000, label: '64K' },
{ value: 128000, label: '128K' },
{ value: 200000, label: '200K' },
{ value: 500000, label: '500K' },
{ value: 1000000, label: '1M' },
{ value: -1, label: '自定义...' }, // 特殊值表示自定义输入
]
// 跟踪哪些阶梯正在使用自定义输入
const customInputMode = reactive<Record<number, boolean>>({})
const customInputValue = reactive<Record<number, string>>({})
// 初始化
watch(
() => props.modelValue,
(newValue) => {
if (newValue?.tiers) {
localTiers.value = newValue.tiers.map(t => ({ ...t }))
// 如果已有缓存价格,标记为手动设置
newValue.tiers.forEach((t, i) => {
const has1hCache = t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60) ?? false
cacheManuallySet[i] = {
creation: t.cache_creation_price_per_1m != null,
read: t.cache_read_price_per_1m != null,
cache1h: has1hCache,
}
})
} else {
localTiers.value = [{
up_to: null,
input_price_per_1m: 0,
output_price_per_1m: 0,
}]
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
}
},
{ immediate: true }
)
// 监听 showCache1h 变化
watch(
() => props.showCache1h,
(newValue, oldValue) => {
if (oldValue === true && newValue === false) {
// 取消勾选时,清除本地的 1h 缓存数据和手动设置标记
localTiers.value.forEach((tier, i) => {
tier.cache_ttl_pricing = undefined
if (cacheManuallySet[i]) {
cacheManuallySet[i].cache1h = false
}
})
syncToParent()
} else if (oldValue === false && newValue === true) {
// 勾选时,同步自动计算的价格到父组件
syncToParent()
}
}
)
// 验证错误
const validationError = computed(() => {
if (localTiers.value.length === 0) {
return '至少需要一个价格阶梯'
}
if (localTiers.value[localTiers.value.length - 1].up_to !== null) {
return '最后一个阶梯必须是无上限的'
}
let prevUpTo = 0
for (let i = 0; i < localTiers.value.length - 1; i++) {
const tier = localTiers.value[i]
if (tier.up_to === null || tier.up_to <= prevUpTo) {
return `阶梯 ${i + 1} 的上限必须大于前一个阶梯`
}
prevUpTo = tier.up_to
}
return null
})
// 获取阶梯起始标签
function getTierStartLabel(index: number): string {
if (index === 0) return '0'
const prevTier = localTiers.value[index - 1]
if (prevTier.up_to === null) return '0'
return formatTokens(prevTier.up_to)
}
// 获取可用的阈值选项
function getAvailableThresholds(index: number) {
const usedThresholds = new Set<number>()
localTiers.value.forEach((t, i) => {
if (i !== index && t.up_to !== null) {
usedThresholds.add(t.up_to)
}
})
const minValue = index > 0 ? (localTiers.value[index - 1].up_to || 0) : 0
const currentValue = localTiers.value[index].up_to
// 过滤可用的预设选项
const options = THRESHOLD_OPTIONS.filter(opt =>
(opt.value === -1) || // "自定义..."始终显示
(!usedThresholds.has(opt.value) && opt.value > minValue)
)
// 如果当前值是自定义的(不在预设中),添加到选项列表
if (currentValue !== null && !THRESHOLD_OPTIONS.some(opt => opt.value === currentValue)) {
options.unshift({ value: currentValue, label: formatTokens(currentValue) })
}
return options
}
// 格式化 token 数量
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`
}
return tokens.toString()
}
// 缓存价格自动计算
function getAutoCacheCreation(index: number): number {
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
return parseFloat((inputPrice * 1.25).toFixed(2))
}
function getAutoCacheRead(index: number): number {
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
return parseFloat((inputPrice * 0.1).toFixed(2))
}
function getAutoCache1h(index: number): number {
const inputPrice = localTiers.value[index]?.input_price_per_1m || 0
return parseFloat((inputPrice * 2).toFixed(2))
}
function getCacheCreationPlaceholder(index: number): string {
const auto = getAutoCacheCreation(index)
return auto > 0 ? auto.toFixed(2) : '自动'
}
function getCacheReadPlaceholder(index: number): string {
const auto = getAutoCacheRead(index)
return auto > 0 ? auto.toFixed(2) : '自动'
}
function getCache1hPlaceholder(index: number): string {
const auto = getAutoCache1h(index)
return auto > 0 ? auto.toFixed(2) : '自动'
}
function getCacheCreationDisplay(index: number): string | number {
const tier = localTiers.value[index]
if (cacheManuallySet[index]?.creation && tier?.cache_creation_price_per_1m != null) {
// 修复浮点数精度问题
return parseFloat(tier.cache_creation_price_per_1m.toFixed(4))
}
return ''
}
function getCacheReadDisplay(index: number): string | number {
const tier = localTiers.value[index]
if (cacheManuallySet[index]?.read && tier?.cache_read_price_per_1m != null) {
// 修复浮点数精度问题
return parseFloat(tier.cache_read_price_per_1m.toFixed(4))
}
return ''
}
function getCache1hDisplay(index: number): string | number {
const tier = localTiers.value[index]
// 只有手动设置过才显示值
if (cacheManuallySet[index]?.cache1h) {
const ttl1h = tier?.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
if (ttl1h) {
// 修复浮点数精度问题
return parseFloat(ttl1h.cache_creation_price_per_1m.toFixed(4))
}
}
return ''
}
// 同步到父组件(包含自动计算的缓存价格)
function syncToParent() {
if (validationError.value) return
const tiers: PricingTier[] = localTiers.value.map((t, i) => {
const tier: PricingTier = {
up_to: t.up_to,
input_price_per_1m: t.input_price_per_1m,
output_price_per_1m: t.output_price_per_1m,
}
// 缓存创建价格:手动设置则用设置值,否则自动计算
if (cacheManuallySet[i]?.creation && t.cache_creation_price_per_1m != null) {
tier.cache_creation_price_per_1m = t.cache_creation_price_per_1m
} else if (t.input_price_per_1m > 0) {
tier.cache_creation_price_per_1m = getAutoCacheCreation(i)
}
// 缓存读取价格:手动设置则用设置值,否则自动计算
if (cacheManuallySet[i]?.read && t.cache_read_price_per_1m != null) {
tier.cache_read_price_per_1m = t.cache_read_price_per_1m
} else if (t.input_price_per_1m > 0) {
tier.cache_read_price_per_1m = getAutoCacheRead(i)
}
// 缓存 TTL 价格1h 缓存)- 只有启用 1h 缓存能力时才处理
if (props.showCache1h) {
if (cacheManuallySet[i]?.cache1h && t.cache_ttl_pricing?.length) {
// 手动设置则用设置值
tier.cache_ttl_pricing = t.cache_ttl_pricing
} else if (t.input_price_per_1m > 0) {
// 否则自动计算
tier.cache_ttl_pricing = [{ ttl_minutes: 60, cache_creation_price_per_1m: getAutoCache1h(i) }]
}
}
return tier
})
emit('update:modelValue', { tiers })
}
function parseFloatInput(value: string | number): number {
const num = typeof value === 'string' ? parseFloat(value) : value
return isNaN(num) ? 0 : num
}
// 更新输入价格(会触发缓存价格自动更新)
function updateInputPrice(index: number, value: number) {
localTiers.value[index].input_price_per_1m = value
syncToParent()
}
function updateOutputPrice(index: number, value: number) {
localTiers.value[index].output_price_per_1m = value
syncToParent()
}
// 获取下拉框当前选中值
function getSelectValue(index: number): number {
const upTo = localTiers.value[index].up_to
if (upTo === null) return -1
return upTo // 直接返回当前值,让下拉框显示对应选项
}
// 处理下拉框选择变化
function handleThresholdChange(index: number, value: number) {
if (value === -1) {
// 选择了"自定义..."进入自定义输入模式
customInputMode[index] = true
customInputValue[index] = ''
} else {
localTiers.value[index].up_to = value
syncToParent()
}
}
// 确认自定义输入
function confirmCustomInput(index: number) {
const inputK = parseInt(customInputValue[index])
if (inputK > 0) {
localTiers.value[index].up_to = inputK * 1000
syncToParent()
}
customInputMode[index] = false
}
function updateCacheCreation(index: number, value: string | number) {
const numValue = parseFloatInput(value)
if (numValue > 0) {
cacheManuallySet[index] = { ...cacheManuallySet[index], creation: true }
localTiers.value[index].cache_creation_price_per_1m = numValue
} else {
// 清空时恢复自动计算
cacheManuallySet[index] = { ...cacheManuallySet[index], creation: false }
localTiers.value[index].cache_creation_price_per_1m = undefined
}
syncToParent()
}
function updateCacheRead(index: number, value: string | number) {
const numValue = parseFloatInput(value)
if (numValue > 0) {
cacheManuallySet[index] = { ...cacheManuallySet[index], read: true }
localTiers.value[index].cache_read_price_per_1m = numValue
} else {
// 清空时恢复自动计算
cacheManuallySet[index] = { ...cacheManuallySet[index], read: false }
localTiers.value[index].cache_read_price_per_1m = undefined
}
syncToParent()
}
function updateCache1h(index: number, value: string | number) {
const numValue = parseFloatInput(value)
const tier = localTiers.value[index]
if (numValue > 0) {
// 手动设置 1 小时缓存创建价格
cacheManuallySet[index] = { ...cacheManuallySet[index], cache1h: true }
tier.cache_ttl_pricing = [{ ttl_minutes: 60, cache_creation_price_per_1m: numValue }]
} else {
// 清空时恢复自动计算
cacheManuallySet[index] = { ...cacheManuallySet[index], cache1h: false }
tier.cache_ttl_pricing = undefined
}
syncToParent()
}
// 阶梯操作
function addTier() {
if (localTiers.value.length === 0) {
localTiers.value = [{
up_to: null,
input_price_per_1m: 0,
output_price_per_1m: 0,
}]
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
} else {
// 把当前最后一个阶梯(无上限)改为有上限
const lastTier = localTiers.value[localTiers.value.length - 1]
const secondLastTier = localTiers.value[localTiers.value.length - 2]
const minValue = secondLastTier?.up_to || 0
const availableThresholds = THRESHOLD_OPTIONS.filter(opt => opt.value > minValue)
const newUpTo = availableThresholds[0]?.value || minValue + 200000
// 给当前最后一个阶梯设置上限
lastTier.up_to = newUpTo
// 添加新的无上限阶梯
const newIndex = localTiers.value.length
const newTier: PricingTier = {
up_to: null,
input_price_per_1m: 0,
output_price_per_1m: 0,
}
localTiers.value.push(newTier)
cacheManuallySet[newIndex] = { creation: false, read: false, cache1h: false }
}
syncToParent()
}
function removeTier(index: number) {
if (localTiers.value.length <= 1) return
localTiers.value.splice(index, 1)
delete cacheManuallySet[index]
// 重新整理 cacheManuallySet 的索引
const newManuallySet: Record<number, { creation: boolean; read: boolean }> = {}
localTiers.value.forEach((_, i) => {
newManuallySet[i] = cacheManuallySet[i] || { creation: false, read: false }
})
Object.keys(cacheManuallySet).forEach(k => delete cacheManuallySet[Number(k)])
Object.assign(cacheManuallySet, newManuallySet)
if (localTiers.value.length > 0) {
localTiers.value[localTiers.value.length - 1].up_to = null
}
syncToParent()
}
</script>

View File

@@ -0,0 +1,4 @@
export { default as GlobalModelFormDialog } from './GlobalModelFormDialog.vue'
export { default as AliasDialog } from './AliasDialog.vue'
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'