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

900 lines
28 KiB
Vue
Raw Normal View History

<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="flex gap-3">
<div
class="space-y-2"
:style="{ flex: currentAuthTypes.length > 1 ? 1 : 'auto', width: currentAuthTypes.length > 1 ? undefined : '100%' }"
>
<Label>认证模板</Label>
<Select
v-model="selectedArchitectureId"
@update:model-value="handleArchitectureChange"
>
<SelectTrigger>
<SelectValue placeholder="选择认证模板" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="arch in architectures"
:key="arch.architecture_id"
:value="arch.architecture_id"
>
{{ arch.display_name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div
v-if="currentAuthTypes.length > 1"
class="space-y-2"
style="flex: 1"
>
<Label>认证方式</Label>
<Select
v-model="selectedAuthType"
@update:model-value="handleAuthTypeChange"
>
<SelectTrigger>
<SelectValue placeholder="选择认证方式" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="authType in currentAuthTypes"
:key="authType.type"
:value="authType.type"
>
{{ authType.display_name }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- 动态表单字段 -->
<template v-if="currentSchema">
<template
v-for="(group, groupIndex) in fieldGroups"
:key="groupIndex"
>
<!-- 可折叠的分组(代理配置 - 代理节点选择) -->
<div
v-if="group.collapsible && group.hasToggle && group.toggleKey"
class="space-y-2"
>
<!-- 标题栏:标题在左,开关在右(在卡片外) -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-foreground">{{ group.title }}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">启用代理</span>
<Switch
:model-value="formData[group.toggleKey] || false"
@update:model-value="handleProxyToggle(group.toggleKey, $event)"
/>
</div>
</div>
<!-- 展开内容(卡片)- 代理节点选择 -->
<div
v-if="formData[group.toggleKey]"
class="rounded-lg border border-border bg-muted/30 px-4 py-3"
>
<ProxyNodeSelect
ref="proxyNodeSelectRef"
:model-value="formData.proxy_node_id || ''"
trigger-class="h-8"
@update:model-value="(v: string) => { formData.proxy_node_id = v; handleFieldChange('proxy_node_id', v) }"
/>
</div>
</div>
<!-- 普通分组(非折叠) -->
<template v-else>
<!-- 分组标题 -->
<div
v-if="group.title"
class="pt-2 text-sm font-medium text-muted-foreground"
>
{{ group.title }}
</div>
<!-- inline 布局:字段横向排列 -->
<div
v-if="group.layout === 'inline'"
class="flex gap-3"
>
<div
v-for="field in group.fields"
:key="field.key"
class="space-y-2"
:style="{ flex: field.flex || 1 }"
>
<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.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
/>
</div>
</div>
<!-- vertical 布局(默认):字段垂直排列 -->
<template v-else>
<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.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 下拉选择 -->
<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"
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 帮助文本 -->
<p
v-if="field.helpText"
class="text-xs text-muted-foreground"
>
{{ field.helpText }}
</p>
</div>
</template>
</template>
</template>
</template>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
额度提醒
</Label>
<p class="mt-1 text-xs text-muted-foreground">
余额低于阈值时通过重要通知发送提醒
</p>
</div>
<Switch v-model="quotaAlert.enabled" />
</div>
<div
v-if="quotaAlert.enabled"
class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3"
>
<div class="space-y-2">
<Label>提醒阈值</Label>
<Input
v-model.number="quotaAlert.threshold_amount"
type="number"
min="0"
step="0.0001"
placeholder="0"
/>
</div>
<div class="space-y-2">
<Label>获取频率(秒)</Label>
<Input
v-model.number="quotaAlert.fetch_interval_seconds"
type="number"
min="30"
max="86400"
step="1"
placeholder="30"
/>
</div>
</div>
</div>
</div>
</form>
<template #footer>
<div class="flex w-full items-center justify-between">
<!-- 左侧:清除按钮(仅在已有配置时显示) -->
<div>
<Button
v-if="hasExistingConfig"
variant="destructive"
:disabled="isClearing"
@click="handleClear"
>
{{ isClearing ? '清除中...' : '清除' }}
</Button>
</div>
<!-- 右侧:验证、保存、取消按钮 -->
<div class="flex gap-2">
<Button
variant="outline"
:disabled="isVerifying || !canVerify"
@click="handleVerify"
>
{{ isVerifying ? '验证中...' : '验证' }}
</Button>
<Button
:disabled="isSaving || !canSave"
@click="handleSave"
>
{{ isSaving ? '保存中...' : '保存' }}
</Button>
<Button
variant="outline"
@click="$emit('update:open', false)"
>
取消
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { KeyRound } from 'lucide-vue-next'
import {
Dialog,
Button,
Input,
Label,
Textarea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from '@/components/ui'
import {
getArchitectures,
saveProviderOpsConfig,
verifyProviderAuth,
getProviderOpsConfig,
deleteProviderOpsConfig,
type ArchitectureInfo,
type QuotaAlertConfig,
} from '@/api/providerOps'
import { parseApiError } from '@/utils/errorParser'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import type { AuthTemplateFieldGroup } from '../auth-templates/types'
import {
schemaToFieldGroups,
buildRequestFromSchema,
parseConfigFromSchema,
validateFromSchema,
formatQuotaFromSchema,
handleSchemaFieldChange,
type CredentialsSchema,
} from '../auth-templates/schema-utils'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
const props = defineProps<{
open: boolean
providerId: string
providerWebsite?: string
currentConfig?: Record<string, unknown> | null
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'saved'): void
}>()
// 敏感字段检测:根据 schema 动态判断
function isSensitiveField(key: string): boolean {
if (!currentSchema.value) return false
const prop = currentSchema.value.properties[key]
return prop?.['x-sensitive'] === true
}
const { success: showSuccess, error: showError } = useToast()
const { confirmDanger } = useConfirm()
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
const proxyNodesStore = useProxyNodesStore()
/** 启用代理时加载节点列表 */
function handleProxyToggle(toggleKey: string, value: boolean) {
formData.value[toggleKey] = value
if (value) {
proxyNodesStore.ensureLoaded()
}
}
// State
const isSaving = ref(false)
const isVerifying = ref(false)
const isLoadingConfig = ref(false)
const isClearing = ref(false)
const verifyStatus = ref<'success' | 'error' | null>(null)
const formChanged = ref(false)
// 敏感字段的 placeholder(存储脱敏后的已保存值)
const sensitivePlaceholders = ref<Record<string, string>>({})
// 是否有已保存的配置(编辑模式)
const hasExistingConfig = ref(false)
// 架构列表(从后端获取)
const architectures = ref<ArchitectureInfo[]>([])
const architecturesLoaded = ref(false)
// 当前选择
const selectedArchitectureId = ref('new_api')
const selectedAuthType = ref('')
const formData = ref<Record<string, unknown>>({})
const quotaAlert = ref<QuotaAlertConfig>({
enabled: false,
threshold_amount: 0,
fetch_interval_seconds: 30,
})
const savedQuotaAlertSignature = ref(quotaAlertSignature(quotaAlert.value))
// 当前架构支持的认证方式
const currentAuthTypes = computed(() => {
const arch = architectures.value.find((a) => a.architecture_id === selectedArchitectureId.value)
return arch?.supported_auth_types ?? []
})
// 当前架构的 schema(优先从选中的 auth_type 获取)
const currentSchema = computed<CredentialsSchema | null>(() => {
const arch = architectures.value.find((a) => a.architecture_id === selectedArchitectureId.value)
if (!arch) return null
// 如果有选中的 auth_type 且架构有多个 connector,从对应的 auth_type 获取 schema
if (selectedAuthType.value && arch.supported_auth_types.length > 1) {
const authType = arch.supported_auth_types.find((t) => t.type === selectedAuthType.value)
if (authType?.credentials_schema) {
return authType.credentials_schema as CredentialsSchema
}
}
return (arch?.credentials_schema as CredentialsSchema) ?? null
})
// 表单是否可以验证(必填字段已填写)
const canVerify = computed(() => {
const schema = currentSchema.value
if (!schema) return false
// 编辑模式下,敏感字段可以为空(使用已保存的值)
let dataToValidate = formData.value
if (hasExistingConfig.value) {
dataToValidate = { ...formData.value }
for (const key of Object.keys(schema.properties)) {
if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
dataToValidate[key] = 'placeholder'
}
}
}
const error = validateFromSchema(schema, dataToValidate)
if (error) return false
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
return !!effectiveBaseUrl
})
// 保存按钮是否可用:验证成功且表单未变动
const quotaAlertChanged = computed(() => {
return quotaAlertSignature(quotaAlert.value) !== savedQuotaAlertSignature.value
})
const canSave = computed(() => {
return (
(verifyStatus.value === 'success' && !formChanged.value)
|| (hasExistingConfig.value && quotaAlertChanged.value && !formChanged.value)
)
})
// 字段分组
const fieldGroups = computed<AuthTemplateFieldGroup[]>(() => {
if (!currentSchema.value) return []
return schemaToFieldGroups(currentSchema.value, props.providerWebsite)
})
// Methods
function handleArchitectureChange() {
// 切换架构时,默认选中第一个认证方式
const authTypes = currentAuthTypes.value
selectedAuthType.value = authTypes.length > 0 ? authTypes[0].type : ''
resetFormData()
verifyStatus.value = null
formChanged.value = true
}
function handleAuthTypeChange() {
resetFormData()
verifyStatus.value = null
formChanged.value = true
}
function handleFieldChange(fieldKey: string, value: unknown) {
formChanged.value = true
// 执行 schema 定义的字段钩子
const schema = currentSchema.value
if (schema) {
handleSchemaFieldChange(schema, fieldKey, value, formData.value)
}
}
// 监听 formData 变化,验证成功后的修改需要重新验证
watch(
formData,
() => {
if (verifyStatus.value === 'success') {
formChanged.value = true
}
},
{ deep: true }
)
function resetFormData() {
const schema = currentSchema.value
if (!schema) {
formData.value = {}
return
}
// 初始化表单数据
const data: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(schema.properties)) {
data[key] = (prop as Record<string, unknown>)['x-default-value'] ?? ''
}
// 代理相关默认值
data.proxy_enabled = false
data.proxy_node_id = ''
formData.value = data
}
function formatQuota(quota: number): string {
const schema = currentSchema.value
if (schema) {
return formatQuotaFromSchema(schema, quota)
}
return quota.toLocaleString()
}
function finiteNumber(value: unknown): number | null {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : null
}
async function handleVerify() {
const schema = currentSchema.value
if (!schema) return
// 验证表单(编辑模式下敏感字段可以为空)
let dataToValidate = formData.value
if (hasExistingConfig.value) {
dataToValidate = { ...formData.value }
for (const key of Object.keys(schema.properties)) {
if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
dataToValidate[key] = 'placeholder'
}
}
}
const error = validateFromSchema(schema, dataToValidate)
if (error) {
showError(error)
return
}
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
}
isVerifying.value = true
try {
const request = buildRequestFromSchema(
schema,
selectedArchitectureId.value,
formData.value,
props.providerWebsite,
)
const verifyRequest = {
...request,
base_url: request.base_url || effectiveBaseUrl,
}
const result = await verifyProviderAuth(props.providerId, verifyRequest)
if (result.success) {
const username = result.data?.username?.trim() || result.data?.display_name?.trim()
const quota = result.data?.quota
if (!username || quota === undefined || quota === null) {
verifyStatus.value = 'error'
const missing: string[] = []
if (!username) missing.push('用户信息')
if (quota === undefined || quota === null) missing.push('余额')
showError(`验证响应缺少: ${missing.join('、')}`)
} else {
verifyStatus.value = 'success'
formChanged.value = false
// Token Rotation: 验证过程中 refresh_token 可能已被轮换,用新值更新表单
// 必须在 formChanged=false 之后执行,避免 watch 将 formChanged 重新设为 true
if (result.updated_credentials) {
for (const [key, value] of Object.entries(result.updated_credentials)) {
formData.value[key] = value
}
// 凭据更新不算用户修改,保持可保存状态
nextTick(() => {
formChanged.value = false
})
}
const displayName = result.data?.display_name || result.data?.username
const extra = result.data?.extra
let balanceText = `余额: ${formatQuota(quota)}`
const extraBalance = finiteNumber(extra?.balance)
const extraPoints = finiteNumber(extra?.points)
if (extraBalance !== null && extraPoints !== null) {
balanceText = `余额: ${formatQuota(extraBalance)} | 积分: ${formatQuota(extraPoints)}`
}
showSuccess(`用户: ${displayName} | ${balanceText}`, '验证成功')
}
} else {
verifyStatus.value = 'error'
// Token Rotation: 即使验证失败,refresh_token 可能已被轮换(旧 token 已失效)
if (result.updated_credentials) {
for (const [key, value] of Object.entries(result.updated_credentials)) {
formData.value[key] = value
}
}
showError(result.message || '验证失败')
}
} catch (error: unknown) {
verifyStatus.value = 'error'
showError(parseApiError(error, '验证失败'))
} finally {
isVerifying.value = false
}
}
async function handleSave() {
const schema = currentSchema.value
if (!schema) return
// 验证表单
let dataToValidate = formData.value
if (hasExistingConfig.value) {
dataToValidate = { ...formData.value }
for (const key of Object.keys(schema.properties)) {
if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
dataToValidate[key] = 'placeholder'
}
}
}
const error = validateFromSchema(schema, dataToValidate)
if (error) {
showError(error)
return
}
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
}
isSaving.value = true
try {
const request = buildRequestFromSchema(
schema,
selectedArchitectureId.value,
formData.value,
props.providerWebsite,
)
request.quota_alert = normalizedQuotaAlert()
const result = await saveProviderOpsConfig(props.providerId, request)
if (result.success) {
savedQuotaAlertSignature.value = quotaAlertSignature(quotaAlert.value)
showSuccess(result.message || '配置已保存', '保存成功')
emit('saved')
emit('update:open', false)
} else {
showError(result.message || '保存失败')
}
} catch (error: unknown) {
showError(parseApiError(error, '保存失败'), '保存失败')
} finally {
isSaving.value = false
}
}
async function handleClear() {
if (!props.providerId) return
const confirmed = await confirmDanger(
'确定要清除该提供商的认证配置吗?清除后将无法进行余额查询、签到等操作。',
'清除认证',
'清除'
)
if (!confirmed) return
isClearing.value = true
try {
const result = await deleteProviderOpsConfig(props.providerId)
if (result.success) {
showSuccess(result.message || '认证信息已清除', '清除成功')
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
verifyStatus.value = null
formChanged.value = false
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
loadQuotaAlert(null)
resetFormData()
emit('saved')
emit('update:open', false)
} else {
showError(result.message || '清除失败')
}
} catch (error: unknown) {
showError(parseApiError(error, '清除失败'), '清除失败')
} finally {
isClearing.value = false
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringOrDefault(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value : fallback
}
function loadFromConfig(config: Record<string, unknown>) {
const connector = isRecord(config.connector) ? config.connector : null
if (!connector) return
hasExistingConfig.value = true
// 根据已保存的 architecture_id 选择对应架构
const architectureId = stringOrDefault(config.architecture_id, 'new_api')
const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
// 从已保存的 connector auth_type 恢复认证方式选择
const savedAuthType = stringOrDefault(connector.auth_type, '')
const authTypes = currentAuthTypes.value
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
selectedAuthType.value = savedAuthType
} else {
selectedAuthType.value = authTypes.length > 0 ? authTypes[0].type : ''
}
const schema = currentSchema.value
if (schema) {
const parsedData = parseConfigFromSchema(schema, {
...config,
connector,
})
// 敏感字段:脱敏值放到 placeholder,表单值设为空
sensitivePlaceholders.value = {}
for (const key of Object.keys(schema.properties)) {
if (isSensitiveField(key) && parsedData[key]) {
sensitivePlaceholders.value[key] = `${parsedData[key]}`
parsedData[key] = ''
}
}
formData.value = parsedData
}
loadQuotaAlert(config.quota_alert)
}
function defaultQuotaAlert(): QuotaAlertConfig {
return {
enabled: false,
threshold_amount: 0,
fetch_interval_seconds: 30,
}
}
function normalizeQuotaAlert(value: unknown): QuotaAlertConfig {
if (!value || typeof value !== 'object') return defaultQuotaAlert()
const item = value as Record<string, unknown>
const threshold = Number(item.threshold_amount)
const interval = Number(item.fetch_interval_seconds)
return {
enabled: item.enabled === true,
threshold_amount: Number.isFinite(threshold) && threshold >= 0 ? threshold : 0,
fetch_interval_seconds: Number.isFinite(interval) && interval >= 30 ? Math.min(Math.floor(interval), 86400) : 30,
}
}
function normalizedQuotaAlert(): QuotaAlertConfig {
return normalizeQuotaAlert(quotaAlert.value)
}
function quotaAlertSignature(value: QuotaAlertConfig): string {
const normalized = normalizeQuotaAlert(value)
return JSON.stringify([
normalized.enabled,
normalized.threshold_amount,
normalized.fetch_interval_seconds,
])
}
function loadQuotaAlert(value: unknown) {
const normalized = normalizeQuotaAlert(value)
quotaAlert.value = normalized
savedQuotaAlertSignature.value = quotaAlertSignature(normalized)
}
/** 确保架构列表已加载 */
async function ensureArchitecturesLoaded(): Promise<void> {
if (architecturesLoaded.value) return
try {
architectures.value = await getArchitectures()
architecturesLoaded.value = true
} catch {
architectures.value = []
}
}
// 打开对话框时初始化
watch(
() => props.open,
async (newVal) => {
if (newVal) {
verifyStatus.value = null
formChanged.value = false
// 确保架构列表已加载
await ensureArchitecturesLoaded()
// 如果传入了 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) {
const configData = {
architecture_id: config.architecture_id,
base_url: config.base_url,
connector: config.connector,
quota_alert: config.quota_alert,
}
loadFromConfig(configData)
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
}
} catch {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
} finally {
isLoadingConfig.value = false
}
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
}
}
}
)
</script>