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:
fawney19
2026-01-17 19:50:35 +08:00
parent 3cdf471473
commit c71027c466
29 changed files with 4653 additions and 144 deletions

View File

@@ -0,0 +1,66 @@
/**
* 提供商认证模板注册表
*
* 集中管理所有认证模板。
*
* ## 添加新模板的步骤
*
* 1. 在 `auth-templates/` 目录下创建新的模板文件(如 `my-api.ts`
* 2. 实现 `AuthTemplate` 接口
* 3. 在本文件中导入并注册到 `templates` 数组
*
* ## 模板需要实现的内容
*
* - `id`: 模板唯一标识(对应后端的 architecture_id
* - `name`: 显示名称
* - `description`: 描述文本
* - `getFields()`: 返回表单字段定义
* - `buildRequest()`: 构建后端 API 请求
* - `parseConfig()`: 从已有配置解析表单数据
* - `validate()`: 验证表单数据
* - `formatQuota()`: (可选)格式化 quota 显示
*/
import type { AuthTemplate, AuthTemplateRegistry } from './types'
import { newApiTemplate } from './new-api'
// ==================== 模板注册 ====================
// 在这里添加新模板
const templates: AuthTemplate[] = [newApiTemplate]
// ==================== 注册表实现 ====================
const templateMap = new Map<string, AuthTemplate>()
// 初始化 Map
templates.forEach((template) => {
templateMap.set(template.id, template)
})
/**
* 认证模板注册表
*/
export const authTemplateRegistry: AuthTemplateRegistry = {
getAll(): AuthTemplate[] {
return templates
},
get(id: string): AuthTemplate | undefined {
return templateMap.get(id)
},
getDefault(): AuthTemplate {
return templates[0]
},
register(template: AuthTemplate): void {
templates.push(template)
templateMap.set(template.id, template)
},
}
// ==================== 导出 ====================
export * from './types'
export { newApiTemplate } from './new-api'

View File

@@ -0,0 +1,97 @@
/**
* New API 认证模板
*
* 适用于 New API 风格的中转站:
* - 使用 Bearer Token 认证
* - 需要 New-Api-User Header 传递用户 ID
* - quota 单位通常是 1/500000 美元
*/
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
export const newApiTemplate: AuthTemplate = {
id: 'new_api',
name: 'New API',
description: '适用于 New API 风格的中转站,使用 Bearer Token + New-Api-User Header',
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
return [
{
fields: [
{
key: 'base_url',
label: 'API 地址',
type: 'text',
placeholder: providerWebsite || 'https://api.example.com',
helpText: '提供商的 API 基础地址,留空则使用提供商官网',
required: false,
},
{
key: 'api_key',
label: '访问令牌 (API Key)',
type: 'password',
placeholder: 'sk-xxx',
required: true,
sensitive: true,
},
{
key: 'user_id',
label: '用户 ID',
type: 'text',
placeholder: '用户 ID',
required: true,
},
],
},
]
},
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
const baseUrl = formData.base_url || providerWebsite || ''
return {
architecture_id: 'new_api',
base_url: baseUrl,
connector: {
auth_type: 'api_key',
config: {
auth_method: 'bearer',
},
credentials: {
api_key: formData.api_key,
user_id: formData.user_id,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
return {
base_url: config?.base_url || '',
api_key: config?.connector?.credentials?.api_key || '',
user_id: config?.connector?.credentials?.user_id || '',
}
},
validate(formData: Record<string, any>): string | null {
if (!formData.api_key?.trim()) {
return '请填写访问令牌'
}
if (!formData.user_id?.trim()) {
return '请填写用户 ID'
}
return null
},
formatQuota(quota: number): string {
// New API 的 quota 单位是 1/500000 美元
const usd = quota / 500000
if (usd >= 1) {
return `$${usd.toFixed(2)}`
}
return `$${usd.toFixed(4)}`
},
}

View File

@@ -0,0 +1,120 @@
/**
* 提供商认证模板类型定义
*
* 认证模板定义了:
* - 需要收集的表单字段
* - 如何构建后端请求
* - 如何解析已有配置
*/
import type { SaveConfigRequest } from '@/api/providerOps'
/**
* 表单字段类型
*/
export type FieldType = 'text' | 'password' | 'select' | 'textarea'
/**
* 表单字段定义
*/
export interface AuthTemplateField {
/** 字段 key用于表单数据 */
key: string
/** 显示标签 */
label: string
/** 字段类型 */
type: FieldType
/** 占位符 */
placeholder?: string
/** 帮助文本 */
helpText?: string
/** 是否必填 */
required?: boolean
/** 是否为敏感字段(使用 masked 输入) */
sensitive?: boolean
/** select 类型的选项 */
options?: Array<{ value: string; label: string }>
/** 默认值 */
defaultValue?: string
}
/**
* 表单字段分组
*/
export interface AuthTemplateFieldGroup {
/** 分组标题(可选,为空则不显示标题) */
title?: string
/** 分组内的字段 */
fields: AuthTemplateField[]
}
/**
* 验证结果数据
*/
export interface VerifyResultData {
username?: string
display_name?: string
email?: string
quota?: number
used_quota?: number
request_count?: number
extra?: Record<string, any>
}
/**
* 认证模板接口
*/
export interface AuthTemplate {
/** 模板 ID对应后端 architecture_id */
id: string
/** 显示名称 */
name: string
/** 描述 */
description: string
/**
* 获取表单字段定义
* @param providerWebsite 提供商官网(用于设置默认 base_url
*/
getFields(providerWebsite?: string): AuthTemplateFieldGroup[]
/**
* 构建保存请求
* @param formData 表单数据
* @param providerWebsite 提供商官网
*/
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest
/**
* 从已有配置解析表单数据
* @param config 已有配置
*/
parseConfig(config: any): Record<string, any>
/**
* 验证表单数据
* @param formData 表单数据
* @returns 错误消息,无错误返回 null
*/
validate(formData: Record<string, any>): string | null
/**
* 格式化验证结果中的 quota 显示
* @param quota quota 值
*/
formatQuota?(quota: number): string
}
/**
* 认证模板注册表类型
*/
export interface AuthTemplateRegistry {
/** 获取所有模板 */
getAll(): AuthTemplate[]
/** 根据 ID 获取模板 */
get(id: string): AuthTemplate | undefined
/** 获取默认模板 */
getDefault(): AuthTemplate
/** 注册模板 */
register(template: AuthTemplate): void
}

View File

@@ -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: '',

View File

@@ -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>

View File

@@ -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'