mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 16:22:27 +08:00
Initial commit
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
title="配置允许的模型"
|
||||
description="选择该 API Key 允许访问的模型,留空则允许访问所有模型"
|
||||
:icon="Settings2"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-4 py-2">
|
||||
<!-- 已选模型展示 -->
|
||||
<div v-if="selectedModels.length > 0" class="space-y-2">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">已选模型 ({{ selectedModels.length }})</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 text-xs hover:text-destructive"
|
||||
@click="clearModels"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 p-2 bg-muted/20 rounded-lg border border-border/40 min-h-[40px]">
|
||||
<Badge
|
||||
v-for="modelName in selectedModels"
|
||||
:key="modelName"
|
||||
variant="secondary"
|
||||
class="text-[11px] px-2 py-0.5 bg-background border-border/60 shadow-sm"
|
||||
>
|
||||
{{ getModelLabel(modelName) }}
|
||||
<button
|
||||
class="ml-0.5 hover:text-destructive focus:outline-none"
|
||||
@click.stop="toggleModel(modelName, false)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表区域 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">可选模型列表</div>
|
||||
<div v-if="!loadingModels && availableModels.length > 0" class="text-[10px] text-muted-foreground/60">
|
||||
共 {{ availableModels.length }} 个模型
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loadingModels" class="flex flex-col items-center justify-center py-12 space-y-3">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-2 border-primary/20 border-t-primary"></div>
|
||||
<span class="text-xs text-muted-foreground">正在加载模型列表...</span>
|
||||
</div>
|
||||
|
||||
<!-- 无模型 -->
|
||||
<div v-else-if="availableModels.length === 0" class="flex flex-col items-center justify-center py-12 text-muted-foreground border border-dashed rounded-lg bg-muted/10">
|
||||
<Box class="w-10 h-10 mb-2 opacity-20" />
|
||||
<span class="text-sm">暂无可选模型</span>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<div v-else class="max-h-[320px] overflow-y-auto pr-1 space-y-1.5 custom-scrollbar">
|
||||
<div
|
||||
v-for="model in availableModels"
|
||||
:key="model.global_model_name"
|
||||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200 cursor-pointer select-none"
|
||||
:class="[
|
||||
selectedModels.includes(model.global_model_name)
|
||||
? 'border-primary/40 bg-primary/5 shadow-sm'
|
||||
: 'border-border/40 bg-background hover:border-primary/20 hover:bg-muted/30'
|
||||
]"
|
||||
@click="toggleModel(model.global_model_name, !selectedModels.includes(model.global_model_name))"
|
||||
>
|
||||
<!-- Checkbox -->
|
||||
<Checkbox
|
||||
:checked="selectedModels.includes(model.global_model_name)"
|
||||
class="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
@click.stop
|
||||
@update:checked="checked => toggleModel(model.global_model_name, checked)"
|
||||
/>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium truncate text-foreground/90">{{ model.display_name }}</span>
|
||||
<span v-if="hasPricing(model)" class="text-[10px] font-mono text-muted-foreground/80 bg-muted/30 px-1.5 py-0.5 rounded border border-border/30 shrink-0">
|
||||
{{ formatPricingShort(model) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-muted-foreground/60 font-mono truncate mt-0.5">
|
||||
{{ model.global_model_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 w-full pt-2">
|
||||
<Button @click="handleCancel" variant="outline" class="h-9">取消</Button>
|
||||
<Button @click="handleSave" :disabled="saving" class="h-9 min-w-[80px]">
|
||||
<Loader2 v-if="saving" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
{{ saving ? '保存中' : '保存配置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Box, Loader2, Settings2 } from 'lucide-vue-next'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
updateEndpointKey,
|
||||
getProviderAvailableSourceModels,
|
||||
type EndpointAPIKey,
|
||||
type ProviderAvailableSourceModel
|
||||
} from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
apiKey: EndpointAPIKey | null
|
||||
providerId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
||||
const selectedModels = ref<string[]>([])
|
||||
const initialModels = ref<string[]>([])
|
||||
|
||||
// 监听对话框打开
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
// 初始化已选模型
|
||||
if (props.apiKey?.allowed_models) {
|
||||
selectedModels.value = [...props.apiKey.allowed_models]
|
||||
initialModels.value = [...props.apiKey.allowed_models]
|
||||
} else {
|
||||
selectedModels.value = []
|
||||
initialModels.value = []
|
||||
}
|
||||
|
||||
// 加载可选模型
|
||||
if (props.providerId) {
|
||||
await loadAvailableModels()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableModels() {
|
||||
if (!props.providerId) return
|
||||
try {
|
||||
loadingModels.value = true
|
||||
const response = await getProviderAvailableSourceModels(props.providerId)
|
||||
availableModels.value = response.models
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '加载模型列表失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const modelLabelMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
availableModels.value.forEach(model => {
|
||||
map.set(model.global_model_name, model.display_name || model.global_model_name)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
function getModelLabel(modelName: string): string {
|
||||
return modelLabelMap.value.get(modelName) ?? modelName
|
||||
}
|
||||
|
||||
function hasPricing(model: ProviderAvailableSourceModel): boolean {
|
||||
const input = model.price.input_price_per_1m ?? 0
|
||||
const output = model.price.output_price_per_1m ?? 0
|
||||
return input > 0 || output > 0
|
||||
}
|
||||
|
||||
function formatPricingShort(model: ProviderAvailableSourceModel): string {
|
||||
const input = model.price.input_price_per_1m ?? 0
|
||||
const output = model.price.output_price_per_1m ?? 0
|
||||
if (input > 0 || output > 0) {
|
||||
return `$${formatPrice(input)}/$${formatPrice(output)}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatPrice(value?: number | null): string {
|
||||
if (value === undefined || value === null || value === 0) return '0'
|
||||
if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function toggleModel(modelName: string, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!selectedModels.value.includes(modelName)) {
|
||||
selectedModels.value = [...selectedModels.value, modelName]
|
||||
}
|
||||
} else {
|
||||
selectedModels.value = selectedModels.value.filter(name => name !== modelName)
|
||||
}
|
||||
}
|
||||
|
||||
function clearModels() {
|
||||
selectedModels.value = []
|
||||
}
|
||||
|
||||
function areArraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort()
|
||||
const sortedB = [...b].sort()
|
||||
return sortedA.every((value, index) => value === sortedB[index])
|
||||
}
|
||||
|
||||
function handleDialogUpdate(value: boolean) {
|
||||
if (!value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.apiKey) return
|
||||
|
||||
// 检查是否有变化
|
||||
const hasChanged = !areArraysEqual(selectedModels.value, initialModels.value)
|
||||
if (!hasChanged) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await updateEndpointKey(props.apiKey.id, {
|
||||
// 空数组时发送 null,表示允许所有模型
|
||||
allowed_models: selectedModels.value.length > 0 ? [...selectedModels.value] : null
|
||||
})
|
||||
success('允许的模型已更新', '成功')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '保存失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground) / 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user