mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 08:12:26 +08:00
Initial commit
This commit is contained in:
337
frontend/src/features/models/components/AliasDialog.vue
Normal file
337
frontend/src/features/models/components/AliasDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
784
frontend/src/features/models/components/ModelDetailDrawer.vue
Normal file
784
frontend/src/features/models/components/ModelDetailDrawer.vue
Normal 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>
|
||||
531
frontend/src/features/models/components/TieredPricingEditor.vue
Normal file
531
frontend/src/features/models/components/TieredPricingEditor.vue
Normal 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>
|
||||
4
frontend/src/features/models/components/index.ts
Normal file
4
frontend/src/features/models/components/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user