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

View File

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