mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: OAuth 导入导出、提供商筛选、Gemini 图像生成支持与流式处理增强
- OAuth: 支持通过 Refresh Token 导入账号(文件拖拽/粘贴),OAuth Key 可导出为 JSON - OAuth: 所有 OAuth 端点添加 require_admin 鉴权 - 提供商管理: 新增状态/API格式/模型三级筛选,后端返回 global_model_ids - Gemini: 新增图像生成模型适配(finalize_provider_request 钩子 + envelope 跳过不兼容字段) - 流式处理: buffer 残留数据 flush 与 token 兜底估算 - 上游元数据: 提取 merge_upstream_metadata,配额耗尽模型保留与深度合并 - Antigravity 配额: 无 quotaInfo 时视为耗尽,移除 Other 兜底分组 - README: 新增升级备份与回滚指南
This commit is contained in:
@@ -58,6 +58,7 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
|
||||
export interface RevealKeyResult {
|
||||
auth_type: 'api_key' | 'vertex_ai' | 'oauth'
|
||||
api_key?: string
|
||||
refresh_token?: string
|
||||
auth_config?: string | Record<string, any>
|
||||
}
|
||||
|
||||
|
||||
@@ -46,3 +46,11 @@ export async function completeProviderLevelOAuth(
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/complete`, data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function importProviderRefreshToken(
|
||||
providerId: string,
|
||||
data: { refresh_token: string; name?: string }
|
||||
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
@@ -447,6 +447,7 @@ export interface ProviderWithEndpointsSummary {
|
||||
active_keys: number
|
||||
total_models: number
|
||||
active_models: number
|
||||
global_model_ids: string[]
|
||||
avg_health_score: number
|
||||
unhealthy_endpoints: number
|
||||
api_formats: string[]
|
||||
|
||||
@@ -6,70 +6,188 @@
|
||||
size="md"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<!-- 加载中 -->
|
||||
<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 class="space-y-4">
|
||||
<!-- Tab 切换 -->
|
||||
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
:class="mode === 'oauth'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
@click="switchMode('oauth')"
|
||||
>
|
||||
获取授权
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
:class="mode === 'import'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
@click="switchMode('import')"
|
||||
>
|
||||
导入授权
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 授权流程 -->
|
||||
<template v-else-if="oauth.authorization_url">
|
||||
<!-- 步骤 1: 打开授权链接 -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
第一步 · 前往授权
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
点击下方按钮在浏览器中完成登录授权
|
||||
</p>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<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>
|
||||
<!-- Tab 内容:grid 叠放,高度取较高者 -->
|
||||
<div class="grid [&>*]:col-start-1 [&>*]:row-start-1">
|
||||
<!-- ===== 获取授权 ===== -->
|
||||
<div
|
||||
class="space-y-4 transition-opacity duration-150"
|
||||
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<div
|
||||
v-if="oauth.starting && !oauth.authorization_url"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
正在准备授权...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="oauth.authorization_url">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
|
||||
<span class="text-xs font-medium">前往授权</span>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-6">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="oauthBusy"
|
||||
@click="openAuthorizationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
打开
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="oauthBusy"
|
||||
@click="copyToClipboard(oauth.authorization_url)"
|
||||
>
|
||||
<Copy class="w-3 h-3 mr-1" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
|
||||
<span class="text-xs font-medium">粘贴回调 URL</span>
|
||||
</div>
|
||||
<div class="pl-6">
|
||||
<Textarea
|
||||
v-model="oauth.callback_url"
|
||||
:disabled="oauthBusy"
|
||||
placeholder="http://localhost:xxx/callback?code=..."
|
||||
class="min-h-[120px] text-xs font-mono break-all !rounded-xl"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<!-- ===== 导入授权 ===== -->
|
||||
<div
|
||||
class="flex flex-col gap-3 transition-opacity duration-150"
|
||||
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
|
||||
<!-- 步骤 2: 粘贴回调地址 -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
第二步 · 粘贴回调
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
授权完成后,复制浏览器地址栏的完整 URL 并粘贴到下方
|
||||
</p>
|
||||
<div class="pt-1">
|
||||
<!-- 主区域:拖拽 或 粘贴输入框(同一位置切换) -->
|
||||
<div v-if="!importText" class="mt-3">
|
||||
<!-- 拖拽模式 -->
|
||||
<div
|
||||
v-if="!showManualInput"
|
||||
class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
|
||||
:class="isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/40'"
|
||||
@click="fileInputRef?.click()"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center py-10 gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-muted/60 flex items-center justify-center">
|
||||
<Upload class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-medium">
|
||||
拖入授权文件或点击选择
|
||||
</p>
|
||||
<p class="text-[10px] text-muted-foreground mt-0.5">
|
||||
支持 .json 格式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 粘贴模式 -->
|
||||
<Textarea
|
||||
v-model="oauth.callback_url"
|
||||
:disabled="oauthBusy"
|
||||
placeholder="http://localhost:xxx/callback?code=..."
|
||||
class="min-h-[80px] text-xs font-mono break-all !rounded-xl"
|
||||
v-else
|
||||
v-model="manualPasteText"
|
||||
:disabled="importing"
|
||||
placeholder="粘贴 Refresh Token 或 JSON 内容"
|
||||
class="min-h-[168px] text-xs font-mono break-all !rounded-xl"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 底部切换链接:占满剩余空间居中 -->
|
||||
<div
|
||||
v-if="!importText"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<button
|
||||
v-if="!showManualInput"
|
||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="showManualInput = true"
|
||||
>
|
||||
或手动粘贴 Refresh Token
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="showManualInput = false"
|
||||
>
|
||||
或选择 JSON 文件导入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 已有内容(文件导入后):显示文本框 -->
|
||||
<div v-if="importText" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">{{ importFileName || '已粘贴内容' }}</span>
|
||||
<button
|
||||
class="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
:disabled="importing"
|
||||
@click="clearImport"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model="importText"
|
||||
:disabled="importing"
|
||||
class="min-h-[160px] text-xs font-mono break-all !rounded-xl"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -80,25 +198,34 @@
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'oauth'"
|
||||
:disabled="!canCompleteOAuth"
|
||||
@click="handleCompleteOAuth"
|
||||
>
|
||||
{{ oauth.completing ? '验证中...' : '验证' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:disabled="!canImport"
|
||||
@click="handleImport"
|
||||
>
|
||||
{{ importing ? '导入中...' : '导入' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Dialog, Button, Textarea, Separator } from '@/components/ui'
|
||||
import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next'
|
||||
import { Dialog, Button, Textarea } from '@/components/ui'
|
||||
import { UserPlus, Copy, ExternalLink, Upload } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
startProviderLevelOAuth,
|
||||
completeProviderLevelOAuth,
|
||||
importProviderRefreshToken,
|
||||
} from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -114,6 +241,10 @@ const emit = defineEmits<{
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 模式
|
||||
type DialogMode = 'oauth' | 'import'
|
||||
const mode = ref<DialogMode>('oauth')
|
||||
|
||||
// OAuth 状态
|
||||
interface OAuthState {
|
||||
authorization_url: string
|
||||
@@ -139,6 +270,15 @@ function createInitialOAuthState(): OAuthState {
|
||||
|
||||
const oauth = ref<OAuthState>(createInitialOAuthState())
|
||||
|
||||
// 导入状态
|
||||
const importText = ref('')
|
||||
const importFileName = ref('')
|
||||
const manualPasteText = ref('')
|
||||
const importing = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const showManualInput = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
|
||||
const oauthBusy = computed(() =>
|
||||
@@ -151,8 +291,41 @@ const canCompleteOAuth = computed(() => {
|
||||
return !oauthBusy.value
|
||||
})
|
||||
|
||||
const canImport = computed(() => {
|
||||
const text = importText.value || manualPasteText.value
|
||||
return text.trim().length > 0 && !importing.value
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
oauth.value = createInitialOAuthState()
|
||||
importText.value = ''
|
||||
importFileName.value = ''
|
||||
manualPasteText.value = ''
|
||||
importing.value = false
|
||||
isDragging.value = false
|
||||
showManualInput.value = false
|
||||
mode.value = 'oauth'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearImport() {
|
||||
importText.value = ''
|
||||
importFileName.value = ''
|
||||
manualPasteText.value = ''
|
||||
showManualInput.value = false
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function switchMode(newMode: DialogMode) {
|
||||
if (mode.value === newMode) return
|
||||
mode.value = newMode
|
||||
if (newMode === 'oauth' && !oauth.value.authorization_url && !oauth.value.starting) {
|
||||
initOAuth()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogUpdate(value: boolean) {
|
||||
@@ -172,7 +345,6 @@ function openAuthorizationUrl() {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// 对话框打开时获取授权 URL(不创建 key)
|
||||
async function initOAuth() {
|
||||
if (!props.providerId) return
|
||||
|
||||
@@ -192,7 +364,6 @@ async function initOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
// 完成授权(此时才创建 key)
|
||||
async function handleCompleteOAuth() {
|
||||
if (!canCompleteOAuth.value || !props.providerId) return
|
||||
oauth.value.completing = true
|
||||
@@ -211,7 +382,80 @@ async function handleCompleteOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
// 监听对话框打开
|
||||
function parseImportText(text: string): { refresh_token: string; name?: string } | null {
|
||||
const trimmed = text.trim()
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const refreshToken = parsed.refresh_token
|
||||
if (typeof refreshToken === 'string' && refreshToken.trim()) {
|
||||
return {
|
||||
refresh_token: refreshToken.trim(),
|
||||
name: parsed.name || parsed.oauth_email || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 不是 JSON
|
||||
}
|
||||
if (trimmed) {
|
||||
return { refresh_token: trimmed }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readFile(file: File) {
|
||||
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
|
||||
showError('仅支持 .json 文件', '格式错误')
|
||||
return
|
||||
}
|
||||
importFileName.value = file.name
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result
|
||||
if (typeof content === 'string') {
|
||||
importText.value = content
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) readFile(file)
|
||||
}
|
||||
|
||||
function handleFileDrop(event: DragEvent) {
|
||||
isDragging.value = false
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (file) readFile(file)
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!canImport.value || !props.providerId) return
|
||||
|
||||
const inputText = importText.value || manualPasteText.value
|
||||
const parsed = parseImportText(inputText)
|
||||
if (!parsed) {
|
||||
showError('无法解析输入内容,请检查格式', '格式错误')
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
await importProviderRefreshToken(props.providerId, parsed)
|
||||
success('导入成功,账号已添加')
|
||||
emit('saved')
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '导入失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
initOAuth()
|
||||
|
||||
@@ -265,10 +265,21 @@
|
||||
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="key.auth_type === 'oauth'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:title="key.auth_type === 'oauth' ? '复制 Refresh Token' : '复制密钥'"
|
||||
title="下载 Refresh Token 授权文件"
|
||||
@click.stop="downloadRefreshToken(key)"
|
||||
>
|
||||
<Download class="w-2.5 h-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
title="复制密钥"
|
||||
@click.stop="copyFullKey(key)"
|
||||
>
|
||||
<Copy class="w-2.5 h-2.5" />
|
||||
@@ -524,15 +535,18 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="group.resetSeconds !== null"
|
||||
v-if="group.resetSeconds !== null || group.usedPercent > 0"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
<template v-if="group.resetSeconds > 0">
|
||||
<template v-if="group.resetSeconds !== null && group.resetSeconds > 0">
|
||||
{{ formatResetTime(group.resetSeconds) }}后重置
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-else-if="group.resetSeconds !== null && group.resetSeconds <= 0">
|
||||
已重置
|
||||
</template>
|
||||
<template v-else>
|
||||
重置时间未知
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -764,6 +778,7 @@ import {
|
||||
Power,
|
||||
GripVertical,
|
||||
Copy,
|
||||
Download,
|
||||
Shield,
|
||||
Shuffle,
|
||||
ExternalLink,
|
||||
@@ -1134,6 +1149,48 @@ async function copyFullKey(key: EndpointAPIKey) {
|
||||
}
|
||||
}
|
||||
|
||||
// 下载 Refresh Token 授权文件
|
||||
async function downloadRefreshToken(key: EndpointAPIKey) {
|
||||
try {
|
||||
const result = await revealEndpointKey(key.id)
|
||||
const refreshToken = result.refresh_token || ''
|
||||
const accessToken = result.api_key || ''
|
||||
|
||||
if (!refreshToken) {
|
||||
showError('该账号没有 Refresh Token,无法导出', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存 access_token 用于显示
|
||||
if (accessToken) {
|
||||
revealedKeys.value.set(key.id, accessToken)
|
||||
}
|
||||
|
||||
const data = {
|
||||
auth_type: 'oauth',
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
name: key.name || '',
|
||||
oauth_email: key.oauth_email || '',
|
||||
exported_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const providerType = provider.value?.provider_type || 'unknown'
|
||||
const safeName = (key.name || key.oauth_email || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
|
||||
a.download = `aether_${providerType}_${safeName}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '获取 Refresh Token 失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteKey(key: EndpointAPIKey) {
|
||||
keyToDelete.value = key
|
||||
deleteKeyConfirmOpen.value = true
|
||||
@@ -1843,7 +1900,6 @@ const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroup[] = [
|
||||
{ key: 'gemini-3-pro', label: 'Gemini 3 Pro', match: m => m.includes('gemini-3-pro') && !m.includes('image') },
|
||||
{ key: 'gemini-3-flash', label: 'Gemini 3 Flash', match: m => m.includes('gemini-3-flash') },
|
||||
{ key: 'gemini-3-pro-image', label: 'Gemini 3 Pro Image', match: m => m.includes('gemini-3-pro-image') },
|
||||
{ key: 'other', label: 'Other', match: () => true },
|
||||
]
|
||||
|
||||
interface AntigravityQuotaSummaryItem {
|
||||
|
||||
@@ -26,6 +26,66 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<Select v-model="filterStatus">
|
||||
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="status in statusFilters"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
{{ status.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- API 格式筛选 -->
|
||||
<Select v-model="filterApiFormat">
|
||||
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="fmt in apiFormatFilters"
|
||||
:key="fmt.value"
|
||||
:value="fmt.value"
|
||||
>
|
||||
{{ fmt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 模型筛选 -->
|
||||
<Select v-model="filterModel">
|
||||
<SelectTrigger class="w-20 sm:w-36 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in modelFilters"
|
||||
:key="model.value"
|
||||
:value="model.value"
|
||||
>
|
||||
{{ model.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 重置筛选 -->
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="重置筛选"
|
||||
@click="searchQuery = ''; filterStatus = 'all'; filterApiFormat = 'all'; filterModel = 'all'"
|
||||
>
|
||||
<FilterX class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 调度策略 -->
|
||||
@@ -73,20 +133,20 @@
|
||||
class="flex flex-col items-center justify-center py-16 text-center"
|
||||
>
|
||||
<div class="text-muted-foreground mb-2">
|
||||
<template v-if="searchQuery">
|
||||
未找到匹配 "{{ searchQuery }}" 的提供商
|
||||
<template v-if="hasActiveFilters">
|
||||
未找到匹配当前筛选条件的提供商
|
||||
</template>
|
||||
<template v-else>
|
||||
暂无提供商,点击右上角添加
|
||||
</template>
|
||||
</div>
|
||||
<Button
|
||||
v-if="searchQuery"
|
||||
v-if="hasActiveFilters"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="searchQuery = ''"
|
||||
@click="searchQuery = ''; filterStatus = 'all'; filterApiFormat = 'all'; filterModel = 'all'"
|
||||
>
|
||||
清除搜索
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -604,7 +664,8 @@ import {
|
||||
ChevronDown,
|
||||
Power,
|
||||
KeyRound,
|
||||
Loader2
|
||||
Loader2,
|
||||
FilterX
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
@@ -618,6 +679,11 @@ import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import Pagination from '@/components/ui/pagination.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components'
|
||||
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -627,6 +693,7 @@ import {
|
||||
getProvidersSummary,
|
||||
deleteProvider,
|
||||
updateProvider,
|
||||
getGlobalModels,
|
||||
type ProviderWithEndpointsSummary,
|
||||
API_FORMAT_SHORT
|
||||
} from '@/api/endpoints'
|
||||
@@ -659,8 +726,44 @@ const balanceCache = ref<Record<string, ActionResultResponse>>({})
|
||||
// 使用普通变量而非 ref,因为不需要响应式,仅用于比较请求版本
|
||||
let balanceLoadVersion = 0
|
||||
|
||||
// 搜索
|
||||
// 搜索与筛选
|
||||
const searchQuery = ref('')
|
||||
const filterStatus = ref('all')
|
||||
const filterApiFormat = ref('all')
|
||||
const filterModel = ref('all')
|
||||
|
||||
// 全局模型数据(用于模型筛选下拉)
|
||||
const globalModels = ref<{ id: string; name: string }[]>([])
|
||||
|
||||
const statusFilters = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'active', label: '活跃' },
|
||||
{ value: 'inactive', label: '已停用' },
|
||||
]
|
||||
|
||||
const apiFormatFilters = [
|
||||
{ value: 'all', label: '全部格式' },
|
||||
{ value: 'claude:chat', label: 'Claude Chat' },
|
||||
{ value: 'claude:cli', label: 'Claude CLI' },
|
||||
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
||||
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
||||
{ value: 'gemini:chat', label: 'Gemini Chat' },
|
||||
{ value: 'gemini:cli', label: 'Gemini CLI' },
|
||||
]
|
||||
|
||||
// 动态计算模型筛选选项:只展示当前提供商列表中实际关联的全局模型
|
||||
const modelFilters = computed(() => {
|
||||
const usedIds = new Set(providers.value.flatMap(p => p.global_model_ids || []))
|
||||
const items = globalModels.value
|
||||
.filter(m => usedIds.has(m.id))
|
||||
.map(m => ({ value: m.id, label: m.name }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
return [{ value: 'all', label: '全部模型' }, ...items]
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return searchQuery.value !== '' || filterStatus.value !== 'all' || filterApiFormat.value !== 'all' || filterModel.value !== 'all'
|
||||
})
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
@@ -693,6 +796,26 @@ const filteredProviders = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if (filterStatus.value !== 'all') {
|
||||
const isActive = filterStatus.value === 'active'
|
||||
result = result.filter(p => p.is_active === isActive)
|
||||
}
|
||||
|
||||
// API 格式筛选
|
||||
if (filterApiFormat.value !== 'all') {
|
||||
result = result.filter(p =>
|
||||
p.api_formats && p.api_formats.includes(filterApiFormat.value)
|
||||
)
|
||||
}
|
||||
|
||||
// 模型筛选
|
||||
if (filterModel.value !== 'all') {
|
||||
result = result.filter(p =>
|
||||
p.global_model_ids && p.global_model_ids.includes(filterModel.value)
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
return result.sort((a, b) => {
|
||||
// 1. 优先显示活跃的提供商
|
||||
@@ -715,8 +838,8 @@ const paginatedProviders = computed(() => {
|
||||
return filteredProviders.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 搜索时重置分页
|
||||
watch(searchQuery, () => {
|
||||
// 搜索/筛选时重置分页
|
||||
watch([searchQuery, filterStatus, filterApiFormat, filterModel], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
@@ -732,6 +855,16 @@ async function loadPriorityMode() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载全局模型列表(用于模型筛选下拉)
|
||||
async function loadGlobalModelList() {
|
||||
try {
|
||||
const response = await getGlobalModels({ is_active: true, limit: 1000 })
|
||||
globalModels.value = response.models.map(m => ({ id: m.id, name: m.name }))
|
||||
} catch {
|
||||
globalModels.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载提供商列表
|
||||
async function loadProviders() {
|
||||
loading.value = true
|
||||
@@ -1179,6 +1312,7 @@ let tickInterval: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
loadPriorityMode()
|
||||
loadGlobalModelList()
|
||||
// 每秒更新一次倒计时
|
||||
tickInterval = setInterval(() => {
|
||||
tickCounter.value++
|
||||
|
||||
Reference in New Issue
Block a user