Files
Aether/frontend/src/features/providers/components/OAuthKeyEditDialog.vue
T

405 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Dialog
:model-value="isOpen"
title="编辑账号"
description="修改 OAuth 账号配置"
:icon="SquarePen"
size="xl"
@update:model-value="handleDialogUpdate"
>
<form
class="space-y-3"
autocomplete="off"
@submit.prevent="handleSave"
>
<!-- 基本信息:账号名称 + 备注 -->
<div class="grid grid-cols-2 gap-3">
<div>
<Label for="name">账号名称 *</Label>
<Input
id="name"
v-model="form.name"
required
placeholder="例如:主账号、备用账号"
maxlength="100"
autocomplete="off"
/>
</div>
<div>
<Label for="note">备注</Label>
<Input
id="note"
v-model="form.note"
placeholder="可选的备注信息"
/>
</div>
</div>
<!-- 配置项 -->
<div class="grid grid-cols-4 gap-3">
<div>
<Label
for="internal_priority"
class="text-xs"
>优先级</Label>
<Input
id="internal_priority"
v-model.number="form.internal_priority"
type="number"
min="0"
placeholder="10"
class="h-8"
/>
<p class="text-xs text-muted-foreground mt-0.5">
越小越优先
</p>
</div>
<div>
<Label
for="rpm_limit"
class="text-xs"
>RPM 限制</Label>
<Input
id="rpm_limit"
:model-value="form.rpm_limit ?? ''"
type="number"
min="1"
max="10000"
placeholder="自适应"
class="h-8"
@update:model-value="(v) => form.rpm_limit = parseNullableNumberInput(v, { min: 1, max: 10000 })"
/>
<p class="text-xs text-muted-foreground mt-0.5">
留空自适应
</p>
</div>
<div>
<Label
for="concurrent_limit"
class="text-xs"
>并发请求上限</Label>
<Input
id="concurrent_limit"
:model-value="form.concurrent_limit ?? ''"
type="number"
min="0"
placeholder="不限制"
class="h-8"
@update:model-value="(v) => form.concurrent_limit = parseNullableNumberInput(v, { min: 0 })"
/>
<p class="text-xs text-muted-foreground mt-0.5">
留空或 0 表示不限制
</p>
</div>
<div>
<Label
for="cache_ttl_minutes"
class="text-xs"
>缓存 TTL</Label>
<Input
id="cache_ttl_minutes"
:model-value="form.cache_ttl_minutes ?? ''"
type="number"
min="0"
max="60"
class="h-8"
@update:model-value="(v) => form.cache_ttl_minutes = parseNumberInput(v, { min: 0, max: 60 }) ?? 5"
/>
<p class="text-xs text-muted-foreground mt-0.5">
分钟,0禁用
</p>
</div>
<div>
<Label
for="max_probe_interval_minutes"
class="text-xs"
>熔断探测</Label>
<Input
id="max_probe_interval_minutes"
:model-value="form.max_probe_interval_minutes ?? ''"
type="number"
min="0"
max="32"
placeholder="32"
class="h-8"
@update:model-value="(v) => form.max_probe_interval_minutes = parseNumberInput(v, { min: 0, max: 32 }) ?? 32"
/>
<p class="text-xs text-muted-foreground mt-0.5">
0-32分钟
</p>
</div>
</div>
<!-- 自动获取模型 -->
<div class="space-y-3 py-2 px-3 rounded-md border border-border/60 bg-muted/30">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label class="text-sm font-medium">自动获取上游可用模型</Label>
<p class="text-xs text-muted-foreground">
定时更新上游模型, 配合模型映射使用
</p>
<p
v-if="showAutoFetchWarning"
class="text-xs text-amber-600 dark:text-amber-400"
>
{{ autoFetchWarningMessage }}
</p>
</div>
<Switch v-model="form.auto_fetch_models" />
</div>
<!-- 模型过滤规则(仅当开启自动获取时显示) -->
<div
v-if="form.auto_fetch_models"
class="space-y-2 pt-2 border-t border-border/40"
>
<div class="grid grid-cols-2 gap-3">
<div>
<Label class="text-xs">包含规则</Label>
<Input
v-model="form.model_include_patterns_text"
placeholder="gpt-*, claude-*, 留空包含全部"
class="h-8 text-sm"
/>
</div>
<div>
<Label class="text-xs">排除规则</Label>
<Input
v-model="form.model_exclude_patterns_text"
placeholder="*-preview, *-beta"
class="h-8 text-sm"
/>
</div>
</div>
<p class="text-xs text-muted-foreground">
逗号分隔,支持 * ? 通配符,不区分大小写
</p>
</div>
</div>
</form>
<template #footer>
<Button
variant="outline"
@click="handleCancel"
>
取消
</Button>
<Button
:disabled="saving || !canSave"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
import { SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
import {
updateProviderKey,
type EndpointAPIKey,
type EndpointAPIKeyUpdate,
} from '@/api/endpoints'
const props = defineProps<{
open: boolean
editingKey: EndpointAPIKey | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const { success, error: showError } = useToast()
const { confirmWarning } = useConfirm()
// 显示自动获取模型警告:编辑模式下,原本未启用但现在启用,且已有 allowed_models
const showAutoFetchWarning = computed(() => {
if (!props.editingKey) return false
// 原本已启用,不需要警告
if (props.editingKey.auto_fetch_models) return false
// 现在未启用,不需要警告
if (!form.value.auto_fetch_models) return false
// 检查是否有已配置的模型权限
const allowedModels = props.editingKey.allowed_models
if (!allowedModels) return false
if (Array.isArray(allowedModels) && allowedModels.length === 0) return false
if (typeof allowedModels === 'object' && Object.keys(allowedModels).length === 0) return false
return true
})
const autoFetchWarningMessage = computed(() => {
if (!showAutoFetchWarning.value || !props.editingKey?.allowed_models) return ''
const models = Array.isArray(props.editingKey.allowed_models)
? props.editingKey.allowed_models
: []
if (models.length === 0) return ''
return `当前 Key 模型权限存在以下模型:${models.map(model => `“${model}”`).join('、')},开启自动获取后将被覆盖`
})
// 表单是否可以保存
const canSave = computed(() => {
// 必须填写名称
if (!form.value.name.trim()) return false
return true
})
const isOpen = computed(() => props.open)
const saving = ref(false)
const form = ref({
name: '',
internal_priority: 10,
rpm_limit: undefined as number | null | undefined,
concurrent_limit: undefined as number | null | undefined,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
auto_fetch_models: false,
model_include_patterns_text: '',
model_exclude_patterns_text: ''
})
// ---------------------------------------------------------------------------
// Dirty 状态跟踪:通过快照比较判断表单是否被修改
// ---------------------------------------------------------------------------
const formSnapshot = ref('')
function takeSnapshot() {
formSnapshot.value = JSON.stringify(form.value)
}
const isDirty = computed(() => {
if (!formSnapshot.value) return false
return JSON.stringify(form.value) !== formSnapshot.value
})
// 对话框关闭时清除快照(快照在 loadKeyData 中拍摄)
watch(isOpen, (val) => {
if (!val) {
formSnapshot.value = ''
}
})
// 重置表单
function resetForm() {
form.value = {
name: '',
internal_priority: 10,
rpm_limit: undefined,
concurrent_limit: undefined,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
auto_fetch_models: false,
model_include_patterns_text: '',
model_exclude_patterns_text: ''
}
formSnapshot.value = ''
}
// 加载密钥数据
function loadKeyData() {
if (!props.editingKey) return
form.value = {
name: props.editingKey.name,
internal_priority: props.editingKey.internal_priority ?? 10,
rpm_limit: props.editingKey.rpm_limit ?? undefined,
concurrent_limit: props.editingKey.concurrent_limit ?? undefined,
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
auto_fetch_models: props.editingKey.auto_fetch_models ?? false,
model_include_patterns_text: (props.editingKey.model_include_patterns || []).join(', '),
model_exclude_patterns_text: (props.editingKey.model_exclude_patterns || []).join(', ')
}
// 数据加载完成后拍快照,作为 dirty 判断的基准
takeSnapshot()
}
// 使用 useFormDialog 统一处理对话框逻辑
const {
handleDialogUpdate: _baseHandleDialogUpdate,
handleCancel: _baseHandleCancel,
} = useFormDialog({
isOpen: () => props.open,
entity: () => props.editingKey,
isLoading: saving,
onClose: () => emit('close'),
loadData: loadKeyData,
resetForm,
})
// 包装关闭逻辑:有未保存更改时弹出确认
async function handleDialogUpdate(value: boolean) {
if (!value && isDirty.value) {
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
if (!confirmed) return
}
_baseHandleDialogUpdate(value)
}
async function handleCancel() {
if (isDirty.value) {
const confirmed = await confirmWarning('有未保存的更改,确定要关闭吗?', '放弃更改')
if (!confirmed) return
}
_baseHandleCancel()
}
// 将逗号分隔的文本解析为数组(去空、去重)
function parsePatternText(text: string): string[] {
if (!text.trim()) return []
const patterns = text
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0)
return [...new Set(patterns)]
}
async function handleSave() {
if (!props.editingKey) {
showError('无法保存:缺少账号信息', '错误')
return
}
saving.value = true
try {
const shouldClearAllowedModels = !!props.editingKey.auto_fetch_models && !form.value.auto_fetch_models
const updateData: EndpointAPIKeyUpdate = {
name: form.value.name,
internal_priority: form.value.internal_priority,
rpm_limit: form.value.rpm_limit,
concurrent_limit: form.value.concurrent_limit,
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
allowed_models: shouldClearAllowedModels ? null : undefined,
auto_fetch_models: form.value.auto_fetch_models,
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)
}
await updateProviderKey(props.editingKey.id, updateData)
success('账号已更新', '成功')
emit('saved')
emit('close')
} catch (err: unknown) {
const errorMessage = parseApiError(err, '保存失败')
showError(errorMessage, '错误')
} finally {
saving.value = false
}
}
</script>