feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化

- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示
- 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查
- 增强端点健康检查器,支持更多检测策略
- 重构 codex 服务为 metadata_collectors 模块
- 优化 OpenAI CLI normalizer 代码结构
- 前端: 改进使用量表格、统计图表、指南页面和异步任务管理
- 扩展多个数据库字符串列为 TEXT 类型
- 新增倒计时 composable 和 provider OAuth API 端点
This commit is contained in:
fawney19
2026-02-04 23:59:45 +08:00
parent 24c9105628
commit 4d6e7c094f
64 changed files with 3885 additions and 930 deletions

View File

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

View File

@@ -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 分开管理)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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