mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化
- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示 - 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查 - 增强端点健康检查器,支持更多检测策略 - 重构 codex 服务为 metadata_collectors 模块 - 优化 OpenAI CLI normalizer 代码结构 - 前端: 改进使用量表格、统计图表、指南页面和异步任务管理 - 扩展多个数据库字符串列为 TEXT 类型 - 新增倒计时 composable 和 provider OAuth API 端点
This commit is contained in:
@@ -200,7 +200,9 @@
|
||||
|
||||
<!-- 视频计费(分辨率 × 时长) -->
|
||||
<div class="pt-3 border-t space-y-2">
|
||||
<div class="text-sm font-medium">视频计费(分辨率 × 时长)</div>
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(分辨率 × 时长)
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
@@ -249,7 +251,7 @@
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span></span>
|
||||
<span />
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
|
||||
@@ -533,6 +533,12 @@ const props = defineProps<{
|
||||
providerFormatConversionEnabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'endpointCreated': []
|
||||
'endpointUpdated': []
|
||||
}>()
|
||||
|
||||
// 计算端点级格式转换是否应该被禁用
|
||||
const isEndpointFormatConversionDisabled = computed(() => {
|
||||
return props.systemFormatConversionEnabled || props.providerFormatConversionEnabled
|
||||
@@ -549,12 +555,6 @@ const formatConversionDisabledTooltip = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'endpointCreated': []
|
||||
'endpointUpdated': []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 规则 Select 的展开状态(与 Collapsible 分开管理)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
title="添加账号"
|
||||
:icon="UserPlus"
|
||||
size="md"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- 加载中 -->
|
||||
<div
|
||||
v-if="oauth.starting && !oauth.authorization_url"
|
||||
class="py-12 text-center"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
正在准备授权...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 授权流程 -->
|
||||
<template v-else-if="oauth.authorization_url">
|
||||
<!-- 步骤 1: 打开授权链接 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0">
|
||||
1
|
||||
</div>
|
||||
<span class="text-sm font-medium">打开授权链接</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground pl-7">
|
||||
点击下方按钮在浏览器中完成登录授权
|
||||
</p>
|
||||
<div class="ml-7 p-2.5 rounded-md bg-muted/50 border border-border/50">
|
||||
<p class="text-xs font-mono text-muted-foreground break-all line-clamp-3 leading-relaxed">
|
||||
{{ oauth.authorization_url }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-7">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="oauthBusy"
|
||||
@click="openAuthorizationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5 mr-1.5" />
|
||||
前往授权
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="oauthBusy"
|
||||
@click="copyToClipboard(oauth.authorization_url)"
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5 mr-1.5" />
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 2: 粘贴回调地址 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-medium shrink-0">
|
||||
2
|
||||
</div>
|
||||
<span class="text-sm font-medium">粘贴回调地址</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground pl-7">
|
||||
授权完成后,复制浏览器地址栏的完整 URL 并粘贴到下方
|
||||
</p>
|
||||
<div class="pl-7">
|
||||
<Textarea
|
||||
v-model="oauth.callback_url"
|
||||
:disabled="oauthBusy"
|
||||
placeholder="http://localhost:xxx/callback?code=..."
|
||||
class="min-h-[80px] text-xs font-mono resize-none"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleClose"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!canCompleteOAuth"
|
||||
@click="handleCompleteOAuth"
|
||||
>
|
||||
{{ oauth.completing ? '验证中...' : '完成授权' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Dialog, Button, Textarea } from '@/components/ui'
|
||||
import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
startProviderLevelOAuth,
|
||||
completeProviderLevelOAuth,
|
||||
} from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// OAuth 状态
|
||||
interface OAuthState {
|
||||
authorization_url: string
|
||||
redirect_uri: string
|
||||
instructions: string
|
||||
provider_type: string
|
||||
callback_url: string
|
||||
starting: boolean
|
||||
completing: boolean
|
||||
}
|
||||
|
||||
function createInitialOAuthState(): OAuthState {
|
||||
return {
|
||||
authorization_url: '',
|
||||
redirect_uri: '',
|
||||
instructions: '',
|
||||
provider_type: '',
|
||||
callback_url: '',
|
||||
starting: false,
|
||||
completing: false,
|
||||
}
|
||||
}
|
||||
|
||||
const oauth = ref<OAuthState>(createInitialOAuthState())
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
|
||||
const oauthBusy = computed(() =>
|
||||
oauth.value.starting || oauth.value.completing
|
||||
)
|
||||
|
||||
const canCompleteOAuth = computed(() => {
|
||||
if (!oauth.value.authorization_url) return false
|
||||
if (!oauth.value.callback_url.trim()) return false
|
||||
return !oauthBusy.value
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
oauth.value = createInitialOAuthState()
|
||||
}
|
||||
|
||||
function handleDialogUpdate(value: boolean) {
|
||||
if (!value) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function openAuthorizationUrl() {
|
||||
const url = oauth.value.authorization_url
|
||||
if (!url) return
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// 对话框打开时获取授权 URL(不创建 key)
|
||||
async function initOAuth() {
|
||||
if (!props.providerId) return
|
||||
|
||||
oauth.value.starting = true
|
||||
try {
|
||||
const resp = await startProviderLevelOAuth(props.providerId)
|
||||
oauth.value.authorization_url = resp.authorization_url
|
||||
oauth.value.redirect_uri = resp.redirect_uri
|
||||
oauth.value.instructions = resp.instructions
|
||||
oauth.value.provider_type = resp.provider_type
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '初始化授权失败')
|
||||
showError(errorMessage, '错误')
|
||||
handleClose()
|
||||
} finally {
|
||||
oauth.value.starting = false
|
||||
}
|
||||
}
|
||||
|
||||
// 完成授权(此时才创建 key)
|
||||
async function handleCompleteOAuth() {
|
||||
if (!canCompleteOAuth.value || !props.providerId) return
|
||||
oauth.value.completing = true
|
||||
try {
|
||||
await completeProviderLevelOAuth(props.providerId, {
|
||||
callback_url: oauth.value.callback_url.trim(),
|
||||
})
|
||||
success('授权成功,账号已添加')
|
||||
emit('saved')
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '完成授权失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
oauth.value.completing = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听对话框打开
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
initOAuth()
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -142,7 +142,7 @@
|
||||
{{ ((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100).toFixed(1) }}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="relative w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div class="relative w-full h-2 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="{
|
||||
@@ -172,7 +172,7 @@
|
||||
<div class="p-4 border-b border-border/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
密钥管理
|
||||
{{ provider.provider_type === 'custom' ? '密钥管理' : '账号管理' }}
|
||||
</h3>
|
||||
<Button
|
||||
v-if="endpoints.length > 0"
|
||||
@@ -182,7 +182,7 @@
|
||||
@click="handleAddKeyToFirstEndpoint"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
||||
添加密钥
|
||||
{{ provider.provider_type === 'custom' ? '添加密钥' : '添加账号' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,20 +216,58 @@
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
|
||||
<!-- OAuth 订阅类型标签 -->
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
:class="getOAuthPlanTypeClass(key.oauth_plan_type)"
|
||||
>
|
||||
{{ formatOAuthPlanType(key.oauth_plan_type) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[11px] font-mono text-muted-foreground">
|
||||
{{ key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked }}
|
||||
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked) }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
title="复制密钥"
|
||||
:title="key.auth_type === 'oauth' ? '复制 Refresh Token' : '复制密钥'"
|
||||
@click.stop="copyFullKey(key)"
|
||||
>
|
||||
<Copy class="w-2.5 h-2.5" />
|
||||
</Button>
|
||||
<!-- OAuth 状态(失效/过期/倒计时)和刷新按钮 -->
|
||||
<template v-if="getKeyOAuthExpires(key)">
|
||||
<span
|
||||
class="text-[10px]"
|
||||
:class="{
|
||||
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
|
||||
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
|
||||
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
|
||||
}"
|
||||
:title="getOAuthStatusTitle(key)"
|
||||
>
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:disabled="refreshingOAuthKeyId === key.id"
|
||||
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
|
||||
@click.stop="handleRefreshOAuth(key)"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-2.5 h-2.5"
|
||||
:class="{ 'animate-spin': refreshingOAuthKeyId === key.id }"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,7 +286,7 @@
|
||||
v-if="key.health_score !== undefined"
|
||||
class="flex items-center gap-1 mr-1"
|
||||
>
|
||||
<div class="w-10 h-1.5 bg-muted/80 rounded-full overflow-hidden">
|
||||
<div class="w-10 h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
:class="getHealthScoreBarColor(key.health_score || 0)"
|
||||
@@ -273,6 +311,7 @@
|
||||
<RefreshCw class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="key.auth_type !== 'oauth'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@@ -282,6 +321,7 @@
|
||||
<Shield class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="key.auth_type !== 'oauth'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@@ -311,6 +351,61 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
|
||||
<div
|
||||
v-if="key.upstream_metadata && hasCodexQuotaData(key.upstream_metadata)"
|
||||
class="mt-2 p-2 bg-muted/30 rounded-md"
|
||||
>
|
||||
<!-- 限额并排显示 -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 周限额(7天窗口) -->
|
||||
<div v-if="key.upstream_metadata.primary_used_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">周限额</span>
|
||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.primary_used_percent)">
|
||||
{{ (100 - key.upstream_metadata.primary_used_percent).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.primary_used_percent)"
|
||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.primary_used_percent, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="key.upstream_metadata.primary_reset_seconds"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{{ formatResetTime(key.upstream_metadata.primary_reset_seconds) }}后重置
|
||||
</div>
|
||||
</div>
|
||||
<!-- 5小时限额 -->
|
||||
<div v-if="key.upstream_metadata.secondary_used_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">5H限额</span>
|
||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.secondary_used_percent)">
|
||||
{{ (100 - key.upstream_metadata.secondary_used_percent).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.secondary_used_percent)"
|
||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.secondary_used_percent, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-[9px] text-muted-foreground/70 mt-0.5">
|
||||
<template v-if="key.upstream_metadata.secondary_reset_seconds">
|
||||
{{ formatResetTime(key.upstream_metadata.secondary_reset_seconds) }}后重置
|
||||
</template>
|
||||
<template v-else>
|
||||
已重置
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 -->
|
||||
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
|
||||
<!-- 优先级放最前面,支持点击编辑 -->
|
||||
@@ -459,6 +554,15 @@
|
||||
@edit-created-key="handleEditCreatedKey"
|
||||
/>
|
||||
|
||||
<!-- OAuth 账号对话框 -->
|
||||
<OAuthAccountDialog
|
||||
v-if="open && provider"
|
||||
:open="oauthAccountDialogOpen"
|
||||
:provider-id="provider.id"
|
||||
@close="oauthAccountDialogOpen = false"
|
||||
@saved="handleKeyChanged"
|
||||
/>
|
||||
|
||||
<!-- 模型权限对话框 -->
|
||||
<KeyAllowedModelsEditDialog
|
||||
v-if="open"
|
||||
@@ -526,14 +630,15 @@ import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
|
||||
import { useCountdownTimer, formatCountdown, getOAuthExpiresCountdown } from '@/composables/useCountdownTimer'
|
||||
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import {
|
||||
KeyFormDialog,
|
||||
KeyAllowedModelsEditDialog,
|
||||
ModelsTab,
|
||||
BatchAssignModelsDialog
|
||||
BatchAssignModelsDialog,
|
||||
OAuthAccountDialog
|
||||
} from '@/features/providers/components'
|
||||
import ModelMappingTab from '@/features/providers/components/provider-tabs/ModelMappingTab.vue'
|
||||
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
||||
@@ -545,6 +650,7 @@ import {
|
||||
getProviderKeys,
|
||||
updateProviderKey,
|
||||
revealEndpointKey,
|
||||
refreshProviderOAuth,
|
||||
type ProviderEndpoint,
|
||||
type EndpointAPIKey,
|
||||
type Model,
|
||||
@@ -591,6 +697,7 @@ const endpointDialogOpen = ref(false)
|
||||
// 密钥相关状态
|
||||
const keyFormDialogOpen = ref(false)
|
||||
const keyPermissionsDialogOpen = ref(false)
|
||||
const oauthAccountDialogOpen = ref(false)
|
||||
const currentEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const editingKey = ref<EndpointAPIKey | null>(null)
|
||||
const deleteKeyConfirmOpen = ref(false)
|
||||
@@ -620,6 +727,9 @@ const editingPriorityValue = ref<number>(0)
|
||||
const priorityInputRef = ref<HTMLInputElement[] | null>(null)
|
||||
const prioritySaving = ref(false)
|
||||
|
||||
// OAuth 刷新状态
|
||||
const refreshingOAuthKeyId = ref<string | null>(null)
|
||||
|
||||
// 点击编辑倍率相关状态
|
||||
const editingMultiplierKey = ref<string | null>(null)
|
||||
const editingMultiplierFormat = ref<string | null>(null)
|
||||
@@ -632,6 +742,7 @@ const hasBlockingDialogOpen = computed(() =>
|
||||
endpointDialogOpen.value ||
|
||||
keyFormDialogOpen.value ||
|
||||
keyPermissionsDialogOpen.value ||
|
||||
oauthAccountDialogOpen.value ||
|
||||
deleteKeyConfirmOpen.value ||
|
||||
modelFormDialogOpen.value ||
|
||||
batchAssignDialogOpen.value ||
|
||||
@@ -695,6 +806,7 @@ watch(() => props.open, (newOpen) => {
|
||||
endpointDialogOpen.value = false
|
||||
keyFormDialogOpen.value = false
|
||||
keyPermissionsDialogOpen.value = false
|
||||
oauthAccountDialogOpen.value = false
|
||||
deleteKeyConfirmOpen.value = false
|
||||
batchAssignDialogOpen.value = false
|
||||
|
||||
@@ -759,9 +871,15 @@ function handleAddKey(endpoint: ProviderEndpoint) {
|
||||
keyFormDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 添加密钥(如果有多个端点则添加到第一个)
|
||||
// 添加密钥/账号(如果有多个端点则添加到第一个)
|
||||
function handleAddKeyToFirstEndpoint() {
|
||||
if (endpoints.value.length > 0) {
|
||||
if (endpoints.value.length === 0) return
|
||||
|
||||
// 非自定义提供商:打开 OAuth 账号对话框
|
||||
if (provider.value?.provider_type !== 'custom') {
|
||||
oauthAccountDialogOpen.value = true
|
||||
} else {
|
||||
// 自定义提供商:打开密钥表单对话框
|
||||
handleAddKey(endpoints.value[0])
|
||||
}
|
||||
}
|
||||
@@ -849,6 +967,25 @@ async function handleRecoverKey(key: EndpointAPIKey) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
if (refreshingOAuthKeyId.value) return
|
||||
refreshingOAuthKeyId.value = key.id
|
||||
try {
|
||||
const result = await refreshProviderOAuth(key.id)
|
||||
showSuccess('Token 刷新成功')
|
||||
// 更新本地数据
|
||||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||||
if (keyInList) {
|
||||
keyInList.oauth_expires_at = result.expires_at
|
||||
}
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || 'Token 刷新失败', '错误')
|
||||
} finally {
|
||||
refreshingOAuthKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeyChanged() {
|
||||
await loadEndpoints()
|
||||
// 并行刷新模型列表和模型映射(因为模型权限会影响正则映射预览)
|
||||
@@ -1203,6 +1340,95 @@ function getKeyRateMultiplier(key: EndpointAPIKey, format: string): number {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// OAuth 订阅类型格式化
|
||||
function formatOAuthPlanType(planType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
plus: 'Plus',
|
||||
pro: 'Pro',
|
||||
free: 'Free',
|
||||
team: 'Team',
|
||||
enterprise: 'Enterprise',
|
||||
}
|
||||
return labels[planType] || planType
|
||||
}
|
||||
|
||||
// Codex 剩余额度样式(基于已用百分比计算剩余)
|
||||
function getQuotaRemainingClass(usedPercent: number): string {
|
||||
const remaining = 100 - usedPercent
|
||||
if (remaining <= 10) return 'text-red-600 dark:text-red-400'
|
||||
if (remaining <= 30) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-green-600 dark:text-green-400'
|
||||
}
|
||||
|
||||
// Codex 剩余额度进度条颜色
|
||||
function getQuotaRemainingBarColor(usedPercent: number): string {
|
||||
const remaining = 100 - usedPercent
|
||||
if (remaining <= 10) return 'bg-red-500 dark:bg-red-400'
|
||||
if (remaining <= 30) return 'bg-yellow-500 dark:bg-yellow-400'
|
||||
return 'bg-green-500 dark:bg-green-400'
|
||||
}
|
||||
|
||||
// 检查是否有 Codex 额度数据
|
||||
function hasCodexQuotaData(metadata: any): boolean {
|
||||
if (!metadata) return false
|
||||
return metadata.primary_used_percent !== undefined ||
|
||||
metadata.secondary_used_percent !== undefined ||
|
||||
(metadata.has_credits && metadata.credits_balance !== undefined)
|
||||
}
|
||||
|
||||
// 格式化重置时间
|
||||
function formatResetTime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分钟`
|
||||
}
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
|
||||
// OAuth 订阅类型样式
|
||||
function getOAuthPlanTypeClass(planType: string): string {
|
||||
const classes: Record<string, string> = {
|
||||
plus: 'border-green-500/50 text-green-600 dark:text-green-400',
|
||||
pro: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
|
||||
free: 'border-primary/50 text-primary',
|
||||
team: 'border-purple-500/50 text-purple-600 dark:text-purple-400',
|
||||
enterprise: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
||||
}
|
||||
return classes[planType] || ''
|
||||
}
|
||||
|
||||
// OAuth 状态信息(包括失效和过期)
|
||||
function getKeyOAuthExpires(key: EndpointAPIKey) {
|
||||
if (key.auth_type !== 'oauth') return null
|
||||
// 即使没有 expires_at,也要检查 invalid_at
|
||||
if (!key.oauth_expires_at && !key.oauth_invalid_at) return null
|
||||
return getOAuthExpiresCountdown(
|
||||
key.oauth_expires_at,
|
||||
countdownTick.value,
|
||||
key.oauth_invalid_at,
|
||||
key.oauth_invalid_reason
|
||||
)
|
||||
}
|
||||
|
||||
// OAuth 状态的 title 提示
|
||||
function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
||||
const status = getKeyOAuthExpires(key)
|
||||
if (!status) return ''
|
||||
if (status.isInvalid) {
|
||||
return status.invalidReason ? `Token 已失效: ${status.invalidReason}` : 'Token 已失效'
|
||||
}
|
||||
if (status.isExpired) {
|
||||
return 'Token 已过期,请重新授权'
|
||||
}
|
||||
return `Token 剩余有效期: ${status.text}`
|
||||
}
|
||||
|
||||
// 健康度颜色
|
||||
function getHealthScoreColor(score: number): string {
|
||||
if (score >= 0.8) return 'text-green-600 dark:text-green-400'
|
||||
|
||||
@@ -29,11 +29,21 @@
|
||||
<SelectValue placeholder="请选择" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="custom">自定义</SelectItem>
|
||||
<SelectItem value="claude_code">ClaudeCode</SelectItem>
|
||||
<SelectItem value="codex">Codex</SelectItem>
|
||||
<SelectItem value="gemini_cli">GeminiCli</SelectItem>
|
||||
<SelectItem value="antigravity">Antigravity</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
自定义
|
||||
</SelectItem>
|
||||
<SelectItem value="claude_code">
|
||||
ClaudeCode
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
Codex
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini_cli">
|
||||
GeminiCli
|
||||
</SelectItem>
|
||||
<SelectItem value="antigravity">
|
||||
Antigravity
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
|
||||
@@ -89,7 +89,9 @@
|
||||
|
||||
<!-- 视频计费(可选覆盖) -->
|
||||
<div class="pt-3 border-t space-y-2">
|
||||
<div class="text-sm font-medium">视频计费(可选覆盖)</div>
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(可选覆盖)
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
@@ -138,7 +140,7 @@
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span></span>
|
||||
<span />
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
@@ -176,7 +178,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -8,6 +8,7 @@ export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vu
|
||||
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
||||
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
|
||||
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
|
||||
export { default as OAuthAccountDialog } from './OAuthAccountDialog.vue'
|
||||
|
||||
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
|
||||
export { default as ProviderAuthDialog } from './ProviderAuthDialog.vue'
|
||||
|
||||
@@ -7,64 +7,64 @@
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
API格式
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
平均响应
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell
|
||||
:colspan="5"
|
||||
class="text-center py-6 text-muted-foreground px-2"
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
API格式
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
平均响应
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell
|
||||
:colspan="5"
|
||||
class="text-center py-6 text-muted-foreground px-2"
|
||||
>
|
||||
暂无API格式统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="item in data"
|
||||
:key="item.api_format"
|
||||
>
|
||||
暂无API格式统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="item in data"
|
||||
:key="item.api_format"
|
||||
>
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ formatApiFormat(item.api_format) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
{{ item.request_count }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(item.total_tokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
|
||||
<span
|
||||
v-if="isAdmin && item.actual_cost !== undefined"
|
||||
class="text-muted-foreground text-[10px]"
|
||||
>
|
||||
{{ formatCurrency(item.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
||||
{{ item.avgResponseTime }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ formatApiFormat(item.api_format) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
{{ item.request_count }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(item.total_tokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
|
||||
<span
|
||||
v-if="isAdmin && item.actual_cost !== undefined"
|
||||
class="text-muted-foreground text-[10px]"
|
||||
>
|
||||
{{ formatCurrency(item.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
||||
{{ item.avgResponseTime }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -7,64 +7,64 @@
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
模型
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
效率
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell
|
||||
:colspan="5"
|
||||
class="text-center py-6 text-muted-foreground px-2"
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
模型
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
效率
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell
|
||||
:colspan="5"
|
||||
class="text-center py-6 text-muted-foreground px-2"
|
||||
>
|
||||
暂无模型统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="model in data"
|
||||
:key="model.model"
|
||||
>
|
||||
暂无模型统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="model in data"
|
||||
:key="model.model"
|
||||
>
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ model.model.replace('claude-', '') }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
{{ model.request_count }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(model.total_tokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
|
||||
<span
|
||||
v-if="isAdmin && model.actual_cost !== undefined"
|
||||
class="text-muted-foreground text-[10px]"
|
||||
>
|
||||
{{ formatCurrency(model.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
||||
{{ model.costPerToken }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ model.model.replace('claude-', '') }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
{{ model.request_count }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(model.total_tokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
|
||||
<span
|
||||
v-if="isAdmin && model.actual_cost !== undefined"
|
||||
class="text-muted-foreground text-[10px]"
|
||||
>
|
||||
{{ formatCurrency(model.actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
||||
{{ model.costPerToken }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -7,70 +7,70 @@
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
提供商
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
成功率
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
平均响应
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell
|
||||
:colspan="6"
|
||||
class="text-center py-6 text-muted-foreground px-2"
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
提供商
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
成功率
|
||||
</TableHead>
|
||||
<TableHead class="h-8 px-2 text-right">
|
||||
平均响应
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="data.length === 0">
|
||||
<TableCell
|
||||
:colspan="6"
|
||||
class="text-center py-6 text-muted-foreground px-2"
|
||||
>
|
||||
暂无提供商统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="provider in data"
|
||||
:key="provider.provider"
|
||||
>
|
||||
暂无提供商统计数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="provider in data"
|
||||
:key="provider.provider"
|
||||
>
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ provider.provider }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
{{ provider.requests }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(provider.totalTokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
|
||||
<span
|
||||
v-if="isAdmin && provider.actualCost !== undefined"
|
||||
class="text-muted-foreground text-[10px]"
|
||||
>
|
||||
{{ formatCurrency(provider.actualCost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
||||
{{ provider.avgResponseTime }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableCell class="font-medium py-2 px-2">
|
||||
{{ provider.provider }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
{{ provider.requests }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span>{{ formatTokens(provider.totalTokens) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
|
||||
<span
|
||||
v-if="isAdmin && provider.actualCost !== undefined"
|
||||
class="text-muted-foreground text-[10px]"
|
||||
>
|
||||
{{ formatCurrency(provider.actualCost) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right text-muted-foreground py-2 px-2">
|
||||
{{ provider.avgResponseTime }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user