Files
Aether/frontend/src/features/providers/components/KeyFormDialog.vue

501 lines
15 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<Dialog
:model-value="isOpen"
:title="isEditMode ? '编辑密钥' : '添加密钥'"
:description="isEditMode ? '修改 API 密钥配置' : '为端点添加新的 API 密钥'"
:icon="isEditMode ? SquarePen : Key"
size="2xl"
@update:model-value="handleDialogUpdate"
>
<form
class="space-y-5"
autocomplete="off"
@submit.prevent="handleSave"
>
<!-- 基本信息 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
基本信息
</h3>
<div class="grid grid-cols-2 gap-4">
2025-12-10 20:52:44 +08:00
<div>
<Label :for="keyNameInputId">密钥名称 *</Label>
2025-12-10 20:52:44 +08:00
<Input
:id="keyNameInputId"
v-model="form.name"
:name="keyNameFieldName"
required
placeholder="例如:主 Key、备用 Key 1"
maxlength="100"
autocomplete="off"
2025-12-10 20:52:44 +08:00
autocapitalize="none"
autocorrect="off"
spellcheck="false"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
<div>
<Label for="rate_multiplier">成本倍率 *</Label>
<Input
id="rate_multiplier"
v-model.number="form.rate_multiplier"
type="number"
step="0.01"
min="0.01"
required
placeholder="1.0"
/>
<p class="text-xs text-muted-foreground mt-1">
真实成本 = 表面成本 × 倍率
2025-12-10 20:52:44 +08:00
</p>
</div>
</div>
2025-12-10 20:52:44 +08:00
<div>
<Label :for="apiKeyInputId">API 密钥 {{ editingKey ? '' : '*' }}</Label>
<Input
:id="apiKeyInputId"
v-model="form.api_key"
:name="apiKeyFieldName"
:type="apiKeyInputType"
:required="!editingKey"
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
:class="getApiKeyInputClass()"
autocomplete="new-password"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
@focus="apiKeyFocused = true"
@blur="apiKeyFocused = form.api_key.trim().length > 0"
/>
<p
v-if="apiKeyError"
class="text-xs text-destructive mt-1"
>
{{ apiKeyError }}
</p>
<p
v-else-if="editingKey"
class="text-xs text-muted-foreground mt-1"
>
留空表示不修改输入新值则覆盖
</p>
</div>
<div>
<Label for="note">备注</Label>
<Input
id="note"
v-model="form.note"
placeholder="可选的备注信息"
/>
</div>
</div>
<!-- 调度与限流 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
调度与限流
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<Label for="internal_priority">内部优先级</Label>
<Input
id="internal_priority"
v-model.number="form.internal_priority"
type="number"
min="0"
/>
<p class="text-xs text-muted-foreground mt-1">
数字越小越优先
</p>
</div>
2025-12-10 20:52:44 +08:00
<div>
<Label for="max_concurrent">最大并发</Label>
2025-12-10 20:52:44 +08:00
<Input
id="max_concurrent"
:model-value="form.max_concurrent ?? ''"
type="number"
min="1"
placeholder="留空启用自适应"
@update:model-value="(v) => form.max_concurrent = parseNumberInput(v)"
2025-12-10 20:52:44 +08:00
/>
<p class="text-xs text-muted-foreground mt-1">
留空 = 自适应模式
</p>
2025-12-10 20:52:44 +08:00
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<Label for="rate_limit">速率限制(/)</Label>
<Input
id="rate_limit"
:model-value="form.rate_limit ?? ''"
type="number"
min="1"
@update:model-value="(v) => form.rate_limit = parseNumberInput(v)"
/>
2025-12-10 20:52:44 +08:00
</div>
<div>
<Label for="daily_limit">每日限制</Label>
<Input
id="daily_limit"
:model-value="form.daily_limit ?? ''"
type="number"
min="1"
@update:model-value="(v) => form.daily_limit = parseNumberInput(v)"
/>
</div>
<div>
<Label for="monthly_limit">每月限制</Label>
<Input
id="monthly_limit"
:model-value="form.monthly_limit ?? ''"
type="number"
min="1"
@update:model-value="(v) => form.monthly_limit = parseNumberInput(v)"
/>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
2025-12-10 20:52:44 +08:00
<!-- 缓存与熔断 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
缓存与熔断
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<Label for="cache_ttl_minutes">缓存 TTL (分钟)</Label>
<Input
id="cache_ttl_minutes"
:model-value="form.cache_ttl_minutes ?? ''"
type="number"
min="0"
max="60"
@update:model-value="(v) => form.cache_ttl_minutes = parseNumberInput(v, { min: 0, max: 60 }) ?? 5"
/>
<p class="text-xs text-muted-foreground mt-1">
0 = 禁用缓存亲和性
</p>
</div>
<div>
<Label for="max_probe_interval_minutes">熔断探测间隔 (分钟)</Label>
<Input
id="max_probe_interval_minutes"
:model-value="form.max_probe_interval_minutes ?? ''"
type="number"
min="2"
max="32"
placeholder="32"
@update:model-value="(v) => form.max_probe_interval_minutes = parseNumberInput(v, { min: 2, max: 32 }) ?? 32"
/>
<p class="text-xs text-muted-foreground mt-1">
范围 2-32 分钟
</p>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
2025-12-10 20:52:44 +08:00
<!-- 能力标签配置 -->
<div
v-if="availableCapabilities.length > 0"
class="space-y-3"
>
<h3 class="text-sm font-medium border-b pb-2">
能力标签
</h3>
<div class="flex flex-wrap gap-2">
<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.capabilities[cap.name] || false"
class="rounded"
@change="form.capabilities[cap.name] = !form.capabilities[cap.name]"
2025-12-10 20:52:44 +08:00
>
<span>{{ cap.display_name }}</span>
</label>
2025-12-10 20:52:44 +08:00
</div>
</div>
</form>
2025-12-10 20:52:44 +08:00
<template #footer>
<Button
variant="outline"
@click="handleCancel"
>
取消
</Button>
<Button
:disabled="saving"
@click="handleSave"
>
2025-12-10 20:52:44 +08:00
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Dialog, Button, Input, Label } from '@/components/ui'
2025-12-10 20:52:44 +08:00
import { Key, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
2025-12-10 20:52:44 +08:00
import {
addEndpointKey,
updateEndpointKey,
getAllCapabilities,
type EndpointAPIKey,
type EndpointAPIKeyUpdate,
2025-12-10 20:52:44 +08:00
type ProviderEndpoint,
type CapabilityDefinition
} from '@/api/endpoints'
const props = defineProps<{
open: boolean
endpoint: ProviderEndpoint | null
editingKey: 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 formNonce = ref(createFieldNonce())
const keyNameInputId = computed(() => `key-name-${formNonce.value}`)
const apiKeyInputId = computed(() => `api-key-${formNonce.value}`)
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)
const apiKeyFocused = ref(false)
const apiKeyInputType = computed(() =>
apiKeyFocused.value || form.value.api_key.trim().length > 0 ? 'password' : 'text'
)
// 可用的能力列表
const availableCapabilities = ref<CapabilityDefinition[]>([])
const form = ref({
name: '',
api_key: '',
rate_multiplier: 1.0,
internal_priority: 50,
max_concurrent: undefined as number | undefined,
rate_limit: undefined as number | undefined,
daily_limit: undefined as number | undefined,
monthly_limit: undefined as number | undefined,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {} as Record<string, boolean>
})
// 加载能力列表
async function loadCapabilities() {
try {
availableCapabilities.value = await getAllCapabilities()
} catch (err) {
log.error('Failed to load capabilities:', err)
2025-12-10 20:52:44 +08:00
}
}
onMounted(() => {
loadCapabilities()
})
// API 密钥输入框样式计算
function getApiKeyInputClass(): string {
const classes = []
if (apiKeyError.value) {
classes.push('border-destructive')
}
if (!apiKeyFocused.value && !form.value.api_key) {
classes.push('text-transparent caret-transparent selection:bg-transparent selection:text-transparent')
}
return classes.join(' ')
}
// API 密钥验证错误信息
const apiKeyError = computed(() => {
const apiKey = form.value.api_key.trim()
if (!apiKey) {
// 新增模式下必填
if (!props.editingKey) {
return '' // 空值由 required 属性处理
}
// 编辑模式下可以为空(表示不修改)
return ''
}
// 如果输入了值,检查长度
if (apiKey.length < 10) {
return 'API 密钥至少需要 10 个字符'
}
return ''
})
// 重置表单
function resetForm() {
formNonce.value = createFieldNonce()
apiKeyFocused.value = false
form.value = {
name: '',
api_key: '',
rate_multiplier: 1.0,
internal_priority: 50,
max_concurrent: undefined,
rate_limit: undefined,
daily_limit: undefined,
monthly_limit: undefined,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {}
}
}
// 加载密钥数据(编辑模式)
function loadKeyData() {
if (!props.editingKey) return
formNonce.value = createFieldNonce()
apiKeyFocused.value = false
form.value = {
name: props.editingKey.name,
api_key: '',
rate_multiplier: props.editingKey.rate_multiplier || 1.0,
internal_priority: props.editingKey.internal_priority ?? 50,
// 保留原始的 null/undefined 状态null 表示自适应模式
max_concurrent: props.editingKey.max_concurrent ?? undefined,
rate_limit: props.editingKey.rate_limit ?? undefined,
daily_limit: props.editingKey.daily_limit ?? undefined,
monthly_limit: props.editingKey.monthly_limit ?? undefined,
2025-12-10 20:52:44 +08:00
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
is_active: props.editingKey.is_active,
capabilities: { ...(props.editingKey.capabilities || {}) }
}
}
// 使用 useFormDialog 统一处理对话框逻辑
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
isOpen: () => props.open,
entity: () => props.editingKey,
isLoading: saving,
onClose: () => emit('close'),
loadData: loadKeyData,
resetForm,
})
function createFieldNonce(): string {
return Math.random().toString(36).slice(2, 10)
}
async function handleSave() {
if (!props.endpoint) return
// 提交前验证
if (apiKeyError.value) {
showError(apiKeyError.value, '验证失败')
return
}
// 新增模式下API 密钥必填
if (!props.editingKey && !form.value.api_key.trim()) {
showError('请输入 API 密钥', '验证失败')
return
}
// 过滤出有效的能力配置(只包含值为 true 的)
const activeCapabilities: Record<string, boolean> = {}
for (const [key, value] of Object.entries(form.value.capabilities)) {
if (value) {
activeCapabilities[key] = true
}
}
const capabilitiesData = Object.keys(activeCapabilities).length > 0 ? activeCapabilities : null
saving.value = true
try {
if (props.editingKey) {
// 更新模式
// 注意max_concurrent 需要显式发送 null 来切换到自适应模式
// undefined 会在 JSON 中被忽略,所以用 null 表示"清空/自适应"
const updateData: EndpointAPIKeyUpdate = {
2025-12-10 20:52:44 +08:00
name: form.value.name,
rate_multiplier: form.value.rate_multiplier,
internal_priority: form.value.internal_priority,
// 显式使用 null 表示自适应模式,这样后端能区分"未提供"和"设置为 null"
// 注意:只有 max_concurrent 需要这种处理,因为它有"自适应模式"的概念
// 其他限制字段rate_limit 等)不支持"清空"操作undefined 会被 JSON 忽略即不更新
max_concurrent: form.value.max_concurrent === undefined ? null : form.value.max_concurrent,
2025-12-10 20:52:44 +08:00
rate_limit: form.value.rate_limit,
daily_limit: form.value.daily_limit,
monthly_limit: form.value.monthly_limit,
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
is_active: form.value.is_active,
capabilities: capabilitiesData
}
if (form.value.api_key.trim()) {
updateData.api_key = form.value.api_key
}
await updateEndpointKey(props.editingKey.id, updateData)
success('密钥已更新', '成功')
} else {
// 新增
await addEndpointKey(props.endpoint.id, {
endpoint_id: props.endpoint.id,
api_key: form.value.api_key,
name: form.value.name,
rate_multiplier: form.value.rate_multiplier,
internal_priority: form.value.internal_priority,
max_concurrent: form.value.max_concurrent,
rate_limit: form.value.rate_limit,
daily_limit: form.value.daily_limit,
monthly_limit: form.value.monthly_limit,
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
capabilities: capabilitiesData || undefined
})
success('密钥已添加', '成功')
}
emit('saved')
emit('close')
} catch (err: any) {
const errorMessage = parseApiError(err, '保存密钥失败')
showError(errorMessage, '错误')
} finally {
saving.value = false
}
}
</script>