mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
feat: 添加 Provider Ops 扩展操作系统,支持余额监控
主要更改: - 新增 Provider Ops 服务框架,支持通过架构配置执行余额查询等扩展操作 - 后端:添加 provider_ops 服务层和 API 路由 - 前端:添加 ProviderAuthDialog 组件配置认证信息 - 前端:添加 providerOps API 和认证模板系统 UI/组件优化: - Input 组件:新增 masked 属性,使用 CSS 遮蔽敏感信息,避免触发密码管理器 - Pagination 组件:移除首页/末页/上下页按钮,改为页码跳转输入框 - KeyFormDialog:使用 masked 属性简化 API Key 输入逻辑 - ProviderManagement:重新设计表格布局,显示余额监控数据
This commit is contained in:
@@ -38,19 +38,9 @@
|
||||
:id="apiKeyInputId"
|
||||
v-model="form.api_key"
|
||||
:name="apiKeyFieldName"
|
||||
:type="apiKeyInputType"
|
||||
masked
|
||||
: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"
|
||||
@@ -329,10 +319,6 @@ 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[]>([])
|
||||
@@ -397,18 +383,6 @@ function updateRateMultiplier(format: string, value: string | number) {
|
||||
form.value.rate_multipliers = newMultipliers
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
@@ -433,7 +407,6 @@ const apiKeyError = computed(() => {
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
formNonce.value = createFieldNonce()
|
||||
apiKeyFocused.value = false
|
||||
form.value = {
|
||||
name: '',
|
||||
api_key: '',
|
||||
@@ -453,7 +426,6 @@ function resetForm() {
|
||||
// 添加成功后清除部分字段以便继续添加
|
||||
function clearForNextAdd() {
|
||||
formNonce.value = createFieldNonce()
|
||||
apiKeyFocused.value = false
|
||||
form.value.name = ''
|
||||
form.value.api_key = ''
|
||||
}
|
||||
@@ -462,7 +434,6 @@ function clearForNextAdd() {
|
||||
function loadKeyData() {
|
||||
if (!props.editingKey) return
|
||||
formNonce.value = createFieldNonce()
|
||||
apiKeyFocused.value = false
|
||||
form.value = {
|
||||
name: props.editingKey.name,
|
||||
api_key: '',
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
title="用户认证"
|
||||
description="配置提供商的用户认证信息,用于余额查询、签到等操作"
|
||||
:icon="KeyRound"
|
||||
size="md"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
<form :name="`provider-auth-${Date.now()}`" autocomplete="off" @submit.prevent>
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="isLoadingConfig"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<div class="text-sm text-muted-foreground">加载配置中...</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- 认证模板选择 -->
|
||||
<div class="space-y-2">
|
||||
<Label>认证模板</Label>
|
||||
<Select
|
||||
v-model="selectedTemplateId"
|
||||
v-model:open="templateSelectOpen"
|
||||
@update:model-value="handleTemplateChange"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择认证模板" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
:value="template.id"
|
||||
>
|
||||
{{ template.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 动态表单字段 -->
|
||||
<template v-if="selectedTemplate">
|
||||
<template
|
||||
v-for="(group, groupIndex) in fieldGroups"
|
||||
:key="groupIndex"
|
||||
>
|
||||
<!-- 分组标题 -->
|
||||
<div
|
||||
v-if="group.title"
|
||||
class="pt-2 text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ group.title }}
|
||||
</div>
|
||||
|
||||
<!-- 字段列表 -->
|
||||
<div
|
||||
v-for="field in group.fields"
|
||||
:key="field.key"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>
|
||||
{{ field.label }}
|
||||
<span
|
||||
v-if="field.required"
|
||||
class="text-muted-foreground/70"
|
||||
>*</span>
|
||||
</Label>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<Input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="formData[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
disable-autofill
|
||||
/>
|
||||
|
||||
<!-- 密码/敏感输入 -->
|
||||
<Input
|
||||
v-else-if="field.type === 'password'"
|
||||
v-model="formData[field.key]"
|
||||
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
|
||||
masked
|
||||
/>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<Select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="formData[field.key]"
|
||||
@update:model-value="handleFieldChange(field.key, $event)"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="field.placeholder || '请选择'" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in field.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 多行文本 -->
|
||||
<Textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="formData[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<!-- 帮助文本 -->
|
||||
<p
|
||||
v-if="field.helpText"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ field.helpText }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:open', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="isSaving || !canSave"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ isSaving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="isVerifying || !canVerify"
|
||||
@click="handleVerify"
|
||||
>
|
||||
{{ isVerifying ? '验证中...' : '验证' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { KeyRound } from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { saveProviderOpsConfig, verifyProviderAuth, getProviderOpsConfig } from '@/api/providerOps'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
authTemplateRegistry,
|
||||
type AuthTemplate,
|
||||
type AuthTemplateFieldGroup,
|
||||
} from '../auth-templates'
|
||||
|
||||
// 敏感字段列表(用于验证和加载配置时的特殊处理)
|
||||
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'cookie_string', 'cookies'] as const
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
providerWebsite?: string
|
||||
currentConfig?: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
|
||||
// State
|
||||
const isSaving = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const isLoadingConfig = ref(false)
|
||||
const verifyStatus = ref<'success' | 'error' | null>(null)
|
||||
const formChanged = ref(false)
|
||||
|
||||
// 敏感字段的 placeholder(存储脱敏后的已保存值)
|
||||
const sensitivePlaceholders = ref<Record<string, string>>({})
|
||||
// 是否有已保存的配置(编辑模式)
|
||||
const hasExistingConfig = ref(false)
|
||||
|
||||
// Select 下拉框状态
|
||||
const templateSelectOpen = ref(false)
|
||||
|
||||
// 模板选择
|
||||
const selectedTemplateId = ref('new_api')
|
||||
const formData = ref<Record<string, any>>({})
|
||||
|
||||
// 表单是否可以验证(必填字段已填写)
|
||||
const canVerify = computed(() => {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) return false
|
||||
|
||||
// 编辑模式下,敏感字段可以为空(使用已保存的值)
|
||||
if (hasExistingConfig.value) {
|
||||
// 创建一个临时数据,把空的敏感字段填充为占位值以通过验证
|
||||
const tempData = { ...formData.value }
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (!tempData[field] && sensitivePlaceholders.value[field]) {
|
||||
tempData[field] = 'placeholder'
|
||||
}
|
||||
}
|
||||
const error = template.validate(tempData)
|
||||
if (error) return false
|
||||
} else {
|
||||
const error = template.validate(formData.value)
|
||||
if (error) return false
|
||||
}
|
||||
|
||||
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
|
||||
return !!effectiveBaseUrl
|
||||
})
|
||||
|
||||
// 保存按钮是否可用:验证成功且表单未变动
|
||||
const canSave = computed(() => {
|
||||
return verifyStatus.value === 'success' && !formChanged.value
|
||||
})
|
||||
|
||||
// Computed
|
||||
const templates = computed(() => authTemplateRegistry.getAll())
|
||||
|
||||
const selectedTemplate = computed<AuthTemplate | undefined>(() => {
|
||||
return authTemplateRegistry.get(selectedTemplateId.value)
|
||||
})
|
||||
|
||||
const fieldGroups = computed<AuthTemplateFieldGroup[]>(() => {
|
||||
if (!selectedTemplate.value) return []
|
||||
return selectedTemplate.value.getFields(props.providerWebsite)
|
||||
})
|
||||
|
||||
// Methods
|
||||
function handleTemplateChange() {
|
||||
// 重置表单数据
|
||||
resetFormData()
|
||||
// 重置验证状态
|
||||
verifyStatus.value = null
|
||||
formChanged.value = true
|
||||
}
|
||||
|
||||
function handleFieldChange(_fieldKey: string, _value: any) {
|
||||
// 标记表单已变动
|
||||
formChanged.value = true
|
||||
}
|
||||
|
||||
// 监听 formData 变化,验证成功后的修改需要重新验证
|
||||
watch(
|
||||
formData,
|
||||
() => {
|
||||
// 验证成功后任何修改都需要重新验证
|
||||
if (verifyStatus.value === 'success') {
|
||||
formChanged.value = true
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function resetFormData() {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) {
|
||||
formData.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化表单数据,设置默认值
|
||||
const data: Record<string, any> = {}
|
||||
const groups = template.getFields(props.providerWebsite)
|
||||
|
||||
for (const group of groups) {
|
||||
for (const field of group.fields) {
|
||||
data[field.key] = field.defaultValue ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
formData.value = data
|
||||
}
|
||||
|
||||
function formatQuota(quota: number): string {
|
||||
const template = selectedTemplate.value
|
||||
if (template?.formatQuota) {
|
||||
return template.formatQuota(quota)
|
||||
}
|
||||
// 默认格式化
|
||||
return quota.toLocaleString()
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) return
|
||||
|
||||
// 验证表单(编辑模式下敏感字段可以为空)
|
||||
let dataToValidate = formData.value
|
||||
if (hasExistingConfig.value) {
|
||||
dataToValidate = { ...formData.value }
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (!dataToValidate[field] && sensitivePlaceholders.value[field]) {
|
||||
dataToValidate[field] = 'placeholder'
|
||||
}
|
||||
}
|
||||
}
|
||||
const error = template.validate(dataToValidate)
|
||||
if (error) {
|
||||
showError(error)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 base_url
|
||||
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
|
||||
if (!effectiveBaseUrl) {
|
||||
showError('请填写 API 地址')
|
||||
return
|
||||
}
|
||||
|
||||
isVerifying.value = true
|
||||
|
||||
try {
|
||||
const request = template.buildRequest(formData.value, props.providerWebsite)
|
||||
// 确保 base_url 是有效字符串,用于 VerifyAuthRequest
|
||||
const verifyRequest = {
|
||||
...request,
|
||||
base_url: request.base_url || effectiveBaseUrl,
|
||||
}
|
||||
const result = await verifyProviderAuth(props.providerId, verifyRequest)
|
||||
|
||||
if (result.success) {
|
||||
verifyStatus.value = 'success'
|
||||
formChanged.value = false // 验证成功后重置表单变动标记
|
||||
// Toast 提示
|
||||
const displayName = result.data?.display_name || result.data?.username || '未知'
|
||||
const quotaStr = result.data?.quota !== undefined ? ` | 余额: ${formatQuota(result.data.quota)}` : ''
|
||||
showSuccess(`用户: ${displayName}${quotaStr}`, '验证成功')
|
||||
} else {
|
||||
verifyStatus.value = 'error'
|
||||
showError(result.message || '验证失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
verifyStatus.value = 'error'
|
||||
const errMsg = error.response?.data?.detail || error.message || '验证失败'
|
||||
showError(errMsg)
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) return
|
||||
|
||||
// 验证表单(编辑模式下敏感字段可以为空)
|
||||
let dataToValidate = formData.value
|
||||
if (hasExistingConfig.value) {
|
||||
dataToValidate = { ...formData.value }
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (!dataToValidate[field] && sensitivePlaceholders.value[field]) {
|
||||
dataToValidate[field] = 'placeholder'
|
||||
}
|
||||
}
|
||||
}
|
||||
const error = template.validate(dataToValidate)
|
||||
if (error) {
|
||||
showError(error)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 base_url
|
||||
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
|
||||
if (!effectiveBaseUrl) {
|
||||
showError('请填写 API 地址')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const request = template.buildRequest(formData.value, props.providerWebsite)
|
||||
const result = await saveProviderOpsConfig(props.providerId, request)
|
||||
if (result.success) {
|
||||
showSuccess(result.message || '配置已保存', '保存成功')
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
} else {
|
||||
showError(result.message || '保存失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
showError(error.response?.data?.detail || error.message, '保存失败')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromConfig(config: any) {
|
||||
if (!config?.connector) return
|
||||
|
||||
hasExistingConfig.value = true
|
||||
|
||||
// 目前只支持 new_api 模板
|
||||
selectedTemplateId.value = 'new_api'
|
||||
|
||||
// 使用模板解析配置
|
||||
const template = authTemplateRegistry.get(selectedTemplateId.value)
|
||||
if (template) {
|
||||
const parsedData = template.parseConfig(config)
|
||||
|
||||
// 敏感字段:脱敏值放到 placeholder,表单值设为空
|
||||
sensitivePlaceholders.value = {}
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (parsedData[field]) {
|
||||
// 保存脱敏值作为 placeholder 提示
|
||||
sensitivePlaceholders.value[field] = `${parsedData[field]}`
|
||||
// 表单值设为空
|
||||
parsedData[field] = ''
|
||||
}
|
||||
}
|
||||
|
||||
formData.value = parsedData
|
||||
}
|
||||
}
|
||||
|
||||
// 打开对话框时初始化
|
||||
watch(
|
||||
() => props.open,
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
verifyStatus.value = null
|
||||
formChanged.value = false
|
||||
|
||||
// 如果传入了 currentConfig,直接使用
|
||||
if (props.currentConfig?.connector) {
|
||||
loadFromConfig(props.currentConfig)
|
||||
return
|
||||
}
|
||||
|
||||
// 否则尝试从后端加载现有配置
|
||||
if (props.providerId) {
|
||||
isLoadingConfig.value = true
|
||||
try {
|
||||
const config = await getProviderOpsConfig(props.providerId)
|
||||
if (config.is_configured && config.architecture_id) {
|
||||
// 构建与 loadFromConfig 兼容的格式
|
||||
const configData = {
|
||||
architecture_id: config.architecture_id,
|
||||
base_url: config.base_url,
|
||||
connector: config.connector,
|
||||
}
|
||||
loadFromConfig(configData)
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
selectedTemplateId.value = 'new_api'
|
||||
resetFormData()
|
||||
}
|
||||
} catch {
|
||||
// 加载失败,使用默认值
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
selectedTemplateId.value = 'new_api'
|
||||
resetFormData()
|
||||
} finally {
|
||||
isLoadingConfig.value = false
|
||||
}
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
selectedTemplateId.value = 'new_api'
|
||||
resetFormData()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -10,3 +10,4 @@ export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
|
||||
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
|
||||
|
||||
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
|
||||
export { default as ProviderAuthDialog } from './ProviderAuthDialog.vue'
|
||||
|
||||
Reference in New Issue
Block a user