refactor: Antigravity/Codex 服务重构为插件化适配器架构

- 将 Antigravity 和 Codex 从独立模块迁移至 src/services/provider/adapters/ 插件体系
- 新增 provider_types 和 oauth_token 模块,移除 maintenance_scheduler 中的 OAuth 定时刷新
- 增强 admin API:扩展 keys 和 provider_query 端点,新增 dashboard 路由
- 大幅增强 ProviderDetailDrawer 组件,新增 AntigravityQuotaDialog
- 改进 handler 基类(chat/cli)和错误分类器
- 优化 fetch_scheduler 和 upstream_fetcher
- 前端 UI 组件清理和优化
- 更新测试以匹配新模块结构
This commit is contained in:
fawney19
2026-02-06 16:37:06 +08:00
parent e01dfee41d
commit b88fb6273b
112 changed files with 5193 additions and 1992 deletions

View File

@@ -146,7 +146,15 @@ export async function updateProviderKey(
}
/**
* 刷新 Provider 的所有 Key 限额信息Codex
* 清除 Key 的 OAuth 失效标记
*/
export async function clearOAuthInvalid(keyId: string): Promise<{ message: string }> {
const response = await client.post(`/api/admin/endpoints/keys/${keyId}/clear-oauth-invalid`)
return response.data
}
/**
* 刷新 Provider 的所有 Key 限额信息Codex / Antigravity
*/
export interface RefreshQuotaResult {
success: number
@@ -156,13 +164,8 @@ export interface RefreshQuotaResult {
key_id: string
key_name: string
status: 'success' | 'no_metadata' | 'error'
metadata?: {
plan_type?: string
primary_used_percent?: number
primary_reset_seconds?: number
secondary_used_percent?: number
secondary_reset_seconds?: number
}
// Codex: 额度字段为扁平结构Antigravity: 返回 { antigravity: { quota_by_model: ... } }
metadata?: Record<string, any>
message?: string
status_code?: number
}>

View File

@@ -272,8 +272,8 @@ export interface EndpointAPIKey {
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
oauth_invalid_at?: number | null // OAuth Token 失效时间Unix 时间戳)
oauth_invalid_reason?: string | null // OAuth Token 失效原因
// 上游元数据(由响应采集,如 Codex 额度信息)
upstream_metadata?: CodexUpstreamMetadata | null
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息
upstream_metadata?: UpstreamMetadata | null
}
// Codex 上游元数据类型
@@ -291,6 +291,21 @@ export interface CodexUpstreamMetadata {
credits_balance?: number // 积分余额
}
export interface AntigravityModelQuota {
remaining_fraction: number // 剩余比例 (0.0-1.0)
used_percent: number // 已用百分比 (0.0-100.0)
reset_time?: string // RFC3339
}
export interface AntigravityUpstreamMetadata {
updated_at?: number // Unix 时间戳(秒)
quota_by_model?: Record<string, AntigravityModelQuota>
}
export interface UpstreamMetadata extends CodexUpstreamMetadata {
antigravity?: AntigravityUpstreamMetadata
}
// 按格式的健康度数据
export interface FormatHealthData {
health_score: number

View File

@@ -1,7 +1,6 @@
<template>
<div class="flex flex-wrap items-center gap-2">
<Select
v-model:open="presetSelectOpen"
v-model="selectedPreset"
>
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
@@ -63,7 +62,6 @@
<Select
v-if="showGranularity"
v-model:open="granularitySelectOpen"
v-model="selectedGranularity"
>
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
@@ -116,8 +114,6 @@ const selectedPreset = ref(props.modelValue.preset || 'last7days')
const startDate = ref(props.modelValue.start_date || '')
const endDate = ref(props.modelValue.end_date || '')
const selectedGranularity = ref(props.modelValue.granularity || 'day')
const presetSelectOpen = ref(false)
const granularitySelectOpen = ref(false)
const showGranularity = computed(() => props.showGranularity !== false)
const allowHourly = computed(() => props.allowHourly === true)

View File

@@ -3,7 +3,6 @@
<template #actions>
<Select
v-if="showMetricSelect"
v-model:open="metricSelectOpen"
:model-value="metric"
@update:model-value="emitMetric"
>
@@ -119,7 +118,6 @@ const emit = defineEmits<{
}>()
const metric = computed(() => props.metric)
const metricSelectOpen = ref(false)
function emitMetric(value: string) {
if (value === 'requests' || value === 'tokens' || value === 'cost') {

View File

@@ -67,6 +67,7 @@
{{ description }}
</p>
</div>
<slot name="header-actions" />
</div>
</div>
</slot>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { DropdownMenuRoot } from 'radix-vue'
interface Props {
@@ -7,19 +8,25 @@ interface Props {
modal?: boolean
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
open: undefined,
modal: true
})
defineEmits<{
'update:open': [value: boolean]
}>()
// open 未传入时不绑定,让 radix-vue 走 uncontrolled 模式
const openProp = computed(() =>
props.open !== undefined ? { open: props.open } : {}
)
</script>
<template>
<DropdownMenuRoot
:default-open="defaultOpen"
:open="open"
v-bind="openProp"
:modal="modal"
@update:open="$emit('update:open', $event)"
>

View File

@@ -7,7 +7,6 @@
</span>
<Select
v-if="showPageSizeSelector"
v-model:open="pageSizeSelectOpen"
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
@@ -100,7 +99,6 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const pageSizeSelectOpen = ref(false)
const jumpPageInput = ref('')
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed } from 'vue'
import { SelectRoot as SelectRootPrimitive } from 'radix-vue'
interface Props {
@@ -14,80 +14,39 @@ interface Props {
required?: boolean
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
modelValue: undefined,
open: undefined,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:open': [value: boolean]
}>()
const internalValue = ref<string | undefined>(
props.modelValue ?? props.defaultValue
// open 未传入时不绑定,让 radix-vue 走 uncontrolled 模式
const openProp = computed(() =>
props.open !== undefined ? { open: props.open } : {}
)
const isModelControlled = computed(() => props.modelValue !== undefined)
watch(
() => props.modelValue,
value => {
if (isModelControlled.value) {
internalValue.value = value
}
}
// modelValue 未传入时不绑定,让 radix-vue 走 uncontrolled 模式
const modelValueProp = computed(() =>
props.modelValue !== undefined ? { modelValue: props.modelValue } : {}
)
const modelValueState = computed({
get: () => (isModelControlled.value ? props.modelValue : internalValue.value),
set: (value: string | undefined) => {
if (!isModelControlled.value) {
internalValue.value = value
}
// Cast to string for the emit signature when value exists
if (value !== undefined) {
emit('update:modelValue', value)
}
}
})
const internalOpen = ref<boolean>(
props.open ?? props.defaultOpen ?? false
)
const isOpenControlled = computed(() => props.open !== undefined)
watch(
() => props.open,
value => {
if (isOpenControlled.value && value !== undefined) {
internalOpen.value = value
}
}
)
const openState = computed({
get: () => (isOpenControlled.value ? props.open : internalOpen.value),
set: (value: boolean) => {
if (!isOpenControlled.value) {
internalOpen.value = value
}
emit('update:open', value)
}
})
</script>
<template>
<SelectRootPrimitive
:default-value="defaultValue"
:model-value="modelValueState"
:open="openState"
v-bind="{ ...modelValueProp, ...openProp }"
:default-open="defaultOpen"
:dir="dir"
:name="name"
:autocomplete="autocomplete"
:disabled="disabled"
:required="required"
@update:model-value="modelValueState = $event"
@update:open="openState = $event"
@update:model-value="emit('update:modelValue', $event)"
@update:open="emit('update:open', $event)"
>
<slot />
</SelectRootPrimitive>

View File

@@ -0,0 +1,234 @@
<template>
<Dialog
:model-value="open"
:title="`配额详情 - ${keyName}`"
:icon="BarChart3"
size="2xl"
:z-index="70"
@update:model-value="$emit('update:open', $event)"
>
<template v-if="providerId && items.length > 0" #header-actions>
<DropdownMenu :modal="false">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="测试模型"
:disabled="!!testingModel"
>
<Loader2
v-if="testingModel"
class="w-3.5 h-3.5 animate-spin"
/>
<Play
v-else
class="w-3.5 h-3.5"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="item in items"
:key="item.model"
@select="handleTestModel(item.model)"
>
{{ item.model }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<div class="py-2">
<div
v-if="items.length > 0"
class="grid grid-cols-2 gap-3"
>
<div
v-for="item in items"
:key="item.model"
>
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span
class="text-muted-foreground truncate mr-2 min-w-0 flex-1"
:title="item.model"
>
{{ item.label }}
</span>
<span :class="getQuotaRemainingClass(item.usedPercent)">
{{ item.remainingPercent.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(item.usedPercent)"
:style="{ width: `${Math.max(item.remainingPercent, 0)}%` }"
/>
</div>
<div
v-if="item.resetSeconds !== null"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
<template v-if="item.resetSeconds > 0">
{{ formatResetTime(item.resetSeconds) }}后重置
</template>
<template v-else>
已重置
</template>
</div>
</div>
</div>
<div
v-else
class="text-center text-sm text-muted-foreground py-8"
>
暂无配额数据
</div>
</div>
<template #footer>
<Button
variant="outline"
@click="$emit('update:open', false)"
>
关闭
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { BarChart3, Play, Loader2 } from 'lucide-vue-next'
import { Dialog } from '@/components/ui'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/components/ui'
import Button from '@/components/ui/button.vue'
import { testModel } from '@/api/endpoints/providers'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
open: boolean
metadata: any
keyName: string
providerId?: string
keyId?: string
}>()
defineEmits<{
'update:open': [value: boolean]
}>()
interface QuotaItem {
model: string
label: string
usedPercent: number
remainingPercent: number
resetSeconds: number | null
}
const { error: showError, success: showSuccess } = useToast()
const testingModel = ref<string | null>(null)
const items = computed<QuotaItem[]>(() => {
const quotaByModel = props.metadata?.antigravity?.quota_by_model
if (!quotaByModel || typeof quotaByModel !== 'object') return []
const result: QuotaItem[] = []
for (const [model, rawInfo] of Object.entries(quotaByModel)) {
if (!model) continue
const info: any = rawInfo || {}
let usedPercent = Number(info.used_percent)
if (!Number.isFinite(usedPercent)) {
const remainingFraction = Number(info.remaining_fraction)
if (Number.isFinite(remainingFraction)) {
usedPercent = (1 - remainingFraction) * 100
} else {
continue
}
}
if (usedPercent < 0) usedPercent = 0
if (usedPercent > 100) usedPercent = 100
const remainingPercent = Math.max(100 - usedPercent, 0)
let resetSeconds: number | null = null
if (typeof info.reset_time === 'string' && info.reset_time.trim()) {
const ts = Date.parse(info.reset_time.trim())
if (!Number.isNaN(ts)) {
const diff = Math.floor((ts - Date.now()) / 1000)
resetSeconds = diff > 0 ? diff : 0
}
}
result.push({ model, label: model, usedPercent, remainingPercent, resetSeconds })
}
result.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
return result
})
async function handleTestModel(modelName: string) {
if (!props.providerId || testingModel.value) return
testingModel.value = modelName
try {
const result = await testModel({
provider_id: props.providerId,
model_name: modelName,
api_key_id: props.keyId,
api_format: 'gemini:cli',
message: 'hello',
})
if (result.success) {
const content =
result.data?.response?.choices?.[0]?.message?.content
|| result.data?.content_preview
if (content) {
showSuccess(`测试成功,响应: ${String(content).substring(0, 100)}${String(content).length > 100 ? '...' : ''}`)
} else {
showSuccess(`模型 "${modelName}" 测试成功`)
}
} else {
showError(`模型测试失败: ${result.error || '未知错误'}`)
}
} catch (err: any) {
const errorMsg = err?.response?.data?.detail || err?.message || '测试请求失败'
showError(`模型测试失败: ${errorMsg}`)
} finally {
testingModel.value = null
}
}
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'
}
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'
}
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}分钟`
}
</script>

View File

@@ -13,7 +13,6 @@
<Label class="text-xs text-muted-foreground">回溯时间</Label>
<Select
v-model="lookbackHours"
v-model:open="selectOpen"
>
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue />
@@ -160,7 +159,6 @@ const loading = ref(false)
const loadingMonitors = ref(false)
const monitors = ref<(EndpointStatusMonitor | PublicEndpointStatusMonitor)[]>([])
const lookbackHours = ref('6')
const selectOpen = ref(false)
async function loadMonitors() {
loadingMonitors.value = true

View File

@@ -283,8 +283,9 @@ async function fetchUpstreamModels() {
errorMessage.value = ''
try {
// 不传 apiKeyId后端会遍历所有 Key 并聚合结果
const response = await adminApi.queryProviderModels(props.providerId)
// 不传 apiKeyId后端会遍历所有 Key 并聚合结果
// 已查询过再点“刷新”时,强制跳过后端缓存,避免长期 TTL 导致模型列表不更新。
const response = await adminApi.queryProviderModels(props.providerId, undefined, hasQueried.value)
if (response.success && response.data?.models) {
upstreamModels.value = response.data.models

View File

@@ -36,7 +36,6 @@
<Label :for="authTypeSelectId">认证类型</Label>
<Select
v-model="form.auth_type"
v-model:open="authTypeSelectOpen"
>
<SelectTrigger :id="authTypeSelectId">
<SelectValue placeholder="选择认证类型" />
@@ -407,7 +406,6 @@ const canSave = computed(() => {
const isOpen = computed(() => props.open)
const saving = ref(false)
const formNonce = ref(createFieldNonce())
const authTypeSelectOpen = ref(false)
const keyNameInputId = computed(() => `key-name-${formNonce.value}`)
const apiKeyInputId = computed(() => `api-key-${formNonce.value}`)
const authTypeSelectId = computed(() => `auth-type-${formNonce.value}`)

View File

@@ -12,7 +12,6 @@
<div class="space-y-1.5">
<Label class="text-xs">目标模型</Label>
<Select
v-model:open="modelSelectOpen"
:model-value="formData.modelId"
:disabled="!!editingGroup"
@update:model-value="handleModelChange"
@@ -300,7 +299,6 @@ const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
// 状态
const submitting = ref(false)
const modelSelectOpen = ref(false)
const loadingModels = ref(false)
const fetchingUpstreamModels = ref(false)
const upstreamModelsLoaded = ref(false)

View File

@@ -30,7 +30,6 @@
<Label>认证模板</Label>
<Select
v-model="selectedTemplateId"
v-model:open="templateSelectOpen"
@update:model-value="handleTemplateChange"
>
<SelectTrigger>
@@ -337,9 +336,6 @@ const sensitivePlaceholders = ref<Record<string, string>>({})
// 是否有已保存的配置(编辑模式)
const hasExistingConfig = ref(false)
// Select 下拉框状态
const templateSelectOpen = ref(false)
// 模板选择
const selectedTemplateId = ref('new_api')
const formData = ref<Record<string, any>>({})

View File

@@ -189,7 +189,7 @@
{{ provider.provider_type === 'custom' ? '密钥管理' : '账号管理' }}
</h3>
<div class="flex items-center gap-2">
<!-- Codex 刷新限额按钮 -->
<!-- 刷新限额按钮Codex会产生少量调用费用Antigravity 采用打开抽屉自动后台刷新 -->
<Button
v-if="provider.provider_type === 'codex' && allKeys.length > 0"
variant="outline"
@@ -275,31 +275,66 @@
</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 v-if="getKeyOAuthExpires(key)?.isInvalid && isAccountLevelBlock(key)">
<Badge
variant="destructive"
class="text-[10px] px-1.5 py-0 shrink-0 gap-0.5"
:title="getOAuthStatusTitle(key)"
>
<ShieldX class="w-2.5 h-2.5" />
账号异常
</Badge>
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0 text-destructive hover:text-destructive"
:disabled="clearingOAuthInvalidKeyId === key.id"
title="清除异常标记(确认账号已完成验证后使用)"
@click.stop="handleClearOAuthInvalid(key)"
>
<RefreshCw
class="w-2.5 h-2.5"
:class="{ 'animate-spin': clearingOAuthInvalidKeyId === key.id }"
/>
</Button>
</template>
<!-- 普通 OAuth 状态 -->
<template v-else>
<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>
</template>
<!-- Antigravity 账号未激活提示 -->
<span
v-if="provider.provider_type === 'antigravity' && key.is_active && key.auth_type === 'oauth' && (!key.upstream_metadata || !hasAntigravityQuotaData(key.upstream_metadata))"
class="text-[10px] text-orange-500 dark:text-orange-400"
title="该账号尚未完成 Gemini Code Assist 激活,无法获取配额和使用模型"
>
账号未激活
</span>
</div>
</div>
</div>
@@ -362,6 +397,16 @@
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
v-if="provider.provider_type === 'antigravity'"
variant="ghost"
size="icon"
class="h-7 w-7"
title="配额详情"
@click="openAntigravityQuotaDialog(key)"
>
<BarChart3 class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -438,6 +483,60 @@
</div>
</div>
</div>
<!-- Antigravity 上游额度摘要按家族分组展示关键配额 -->
<div
v-if="provider.provider_type === 'antigravity' && key.upstream_metadata && hasAntigravityQuotaData(key.upstream_metadata)"
class="mt-2 p-2 bg-muted/30 rounded-md"
>
<div class="flex items-center justify-between mb-1">
<span class="text-[10px] text-muted-foreground">模型配额</span>
<div class="flex items-center gap-1">
<RefreshCw
v-if="refreshingQuota"
class="w-3 h-3 text-muted-foreground/70 animate-spin"
/>
<span
v-if="key.upstream_metadata.antigravity?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div
v-for="group in getAntigravityQuotaSummary(key.upstream_metadata)"
:key="group.key"
>
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground truncate mr-2 min-w-0 flex-1">
{{ group.label }}
</span>
<span :class="getQuotaRemainingClass(group.usedPercent)">
{{ group.remainingPercent.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(group.usedPercent)"
:style="{ width: `${Math.max(group.remainingPercent, 0)}%` }"
/>
</div>
<div
v-if="group.resetSeconds !== null"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
<template v-if="group.resetSeconds > 0">
{{ formatResetTime(group.resetSeconds) }}后重置
</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">
<!-- 优先级放最前面支持点击编辑 -->
@@ -640,6 +739,17 @@
@update:open="batchAssignDialogOpen = $event"
@changed="handleBatchAssignChanged"
/>
<!-- Antigravity 配额详情弹窗 -->
<AntigravityQuotaDialog
v-if="antigravityQuotaDialogKey"
:open="antigravityQuotaDialogOpen"
:metadata="antigravityQuotaDialogKey.upstream_metadata"
:key-name="antigravityQuotaDialogKey.name || '未命名密钥'"
:provider-id="providerId"
:key-id="antigravityQuotaDialogKey.id"
@update:open="antigravityQuotaDialogOpen = $event"
/>
</template>
<script setup lang="ts">
@@ -656,7 +766,9 @@ import {
Copy,
Shield,
Shuffle,
ExternalLink
ExternalLink,
BarChart3,
ShieldX,
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import Button from '@/components/ui/button.vue'
@@ -679,6 +791,7 @@ import ModelMappingTab from '@/features/providers/components/provider-tabs/Model
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
import AlertDialog from '@/components/common/AlertDialog.vue'
import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQuotaDialog.vue'
import {
deleteEndpointKey,
recoverKeyHealth,
@@ -687,6 +800,7 @@ import {
revealEndpointKey,
refreshProviderOAuth,
refreshProviderQuota,
clearOAuthInvalid,
type ProviderEndpoint,
type EndpointAPIKey,
type Model,
@@ -695,6 +809,7 @@ import {
API_FORMAT_SHORT,
sortApiFormats,
} from '@/api/endpoints'
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
// 扩展端点类型,包含密钥列表
interface ProviderEndpointWithKeys extends ProviderEndpoint {
@@ -767,9 +882,16 @@ const prioritySaving = ref(false)
// OAuth 刷新状态
const refreshingOAuthKeyId = ref<string | null>(null)
// Codex 限额刷新状态
// OAuth 失效清除状态
const clearingOAuthInvalidKeyId = ref<string | null>(null)
// 限额刷新状态Codex / Antigravity
const refreshingQuota = ref(false)
// Antigravity 配额详情弹窗状态
const antigravityQuotaDialogOpen = ref(false)
const antigravityQuotaDialogKey = ref<EndpointAPIKey | null>(null)
// 描述编辑状态
const editingDescription = ref(false)
const editingDescriptionValue = ref('')
@@ -791,6 +913,7 @@ const hasBlockingDialogOpen = computed(() =>
deleteKeyConfirmOpen.value ||
modelFormDialogOpen.value ||
batchAssignDialogOpen.value ||
antigravityQuotaDialogOpen.value ||
modelMappingTabRef.value?.dialogOpen
)
@@ -824,46 +947,49 @@ const allKeys = computed(() => {
return result
})
// 监听 providerId 变化
watch(() => props.providerId, (newId) => {
if (newId && props.open) {
loadProvider()
loadEndpoints()
}
}, { immediate: true })
// 合并监听 providerId 和 open避免同一 tick 内两个 watcher 都触发导致重复请求
watch(
[() => props.providerId, () => props.open],
async ([newId, newOpen], [oldId, oldOpen]) => {
if (newOpen && newId) {
await Promise.all([
loadProvider(),
loadEndpoints(),
])
// 仅在抽屉刚打开时启动倒计时
if (newOpen && !oldOpen) {
startCountdownTimer()
}
void autoRefreshAntigravityQuotaInBackground()
} else if (!newOpen && oldOpen) {
// 停止倒计时定时器
stopCountdownTimer()
// 重置所有状态
provider.value = null
endpoints.value = []
providerKeys.value = [] // 清空 Provider 级别的 keys
// 监听 open 变化
watch(() => props.open, (newOpen) => {
if (newOpen && props.providerId) {
loadProvider()
loadEndpoints()
// 启动倒计时定时器
startCountdownTimer()
} else if (!newOpen) {
// 停止倒计时定时器
stopCountdownTimer()
// 重置所有状态
provider.value = null
endpoints.value = []
providerKeys.value = [] // 清空 Provider 级别的 keys
// 重置所有对话框状态
endpointDialogOpen.value = false
keyFormDialogOpen.value = false
keyPermissionsDialogOpen.value = false
oauthAccountDialogOpen.value = false
deleteKeyConfirmOpen.value = false
batchAssignDialogOpen.value = false
antigravityQuotaDialogOpen.value = false
antigravityQuotaDialogKey.value = null
// 重置所有对话框状态
endpointDialogOpen.value = false
keyFormDialogOpen.value = false
keyPermissionsDialogOpen.value = false
oauthAccountDialogOpen.value = false
deleteKeyConfirmOpen.value = false
batchAssignDialogOpen.value = false
// 重置临时数据
currentEndpoint.value = null
editingKey.value = null
keyToDelete.value = null
// 重置临时数据
currentEndpoint.value = null
editingKey.value = null
keyToDelete.value = null
// 清除已显示的密钥(安全考虑)
revealedKeys.value.clear()
}
})
// 清除已显示的密钥(安全考虑)
revealedKeys.value.clear()
}
},
{ immediate: true },
)
// 处理背景点击
function handleBackdropClick() {
@@ -1057,7 +1183,11 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
if (keyInList) {
keyInList.oauth_expires_at = result.expires_at
}
emit('refresh')
// 重新加载 key 数据token 刷新可能补上了 project_id 等信息)
await loadEndpoints()
// Antigravitytoken 刷新后可能完成了账号激活,触发配额获取
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
void autoRefreshAntigravityQuotaInBackground()
} catch (err: any) {
showError(err.response?.data?.detail || 'Token 刷新失败', '错误')
} finally {
@@ -1065,14 +1195,53 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
}
}
// 刷新 Codex 所有账号限额
// 判断是否为账号级别的封禁(刷新 token 无法修复)
function isAccountLevelBlock(key: EndpointAPIKey): boolean {
if (!key.oauth_invalid_reason) return false
return key.oauth_invalid_reason.startsWith('[ACCOUNT_BLOCK]')
}
// 清除 OAuth 失效标记
async function handleClearOAuthInvalid(key: EndpointAPIKey) {
if (clearingOAuthInvalidKeyId.value) return
const confirmed = await confirm({
title: '清除账号异常标记',
message: `确认账号 "${key.name || key.id.slice(0, 8)}" 已手动完成验证?清除后该 Key 将恢复正常调度。`,
confirmText: '确认清除',
variant: 'default',
})
if (!confirmed) return
clearingOAuthInvalidKeyId.value = key.id
try {
await clearOAuthInvalid(key.id)
showSuccess('已清除 OAuth 异常标记')
// 更新本地数据
const keyInList = providerKeys.value.find(k => k.id === key.id)
if (keyInList) {
keyInList.oauth_invalid_at = null
keyInList.oauth_invalid_reason = null
}
await loadEndpoints()
} catch (err: any) {
showError(err.response?.data?.detail || '清除失败', '错误')
} finally {
clearingOAuthInvalidKeyId.value = null
}
}
// 刷新所有账号限额Codex / Antigravity
async function handleRefreshQuota() {
if (refreshingQuota.value || !props.providerId) return
// 确认对话框
const message = provider.value?.provider_type === 'codex'
? '这将使用每个账号发送测试请求以获取最新限额信息,可能产生少量 API 调用费用。是否继续?'
: '这将向每个账号请求一次上游额度信息以更新限额显示。是否继续?'
const confirmed = await confirm({
title: '获取限额',
message: '这将使用每个账号发送测试请求以获取最新限额信息,会产生少量 API 调用费用。是否继续?',
message,
confirmText: '继续',
variant: 'info'
})
@@ -1097,6 +1266,100 @@ async function handleRefreshQuota() {
}
}
// Antigravity打开抽屉后自动后台刷新配额缓存缺失/过期,或 Token 即将过期时触发)
const ANTIGRAVITY_AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
const ANTIGRAVITY_AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
function shouldAutoRefreshAntigravityQuota(): boolean {
if (provider.value?.provider_type !== 'antigravity') return false
const now = Math.floor(Date.now() / 1000)
let hasActiveKey = false
for (const { key } of allKeys.value) {
if (!key.is_active) continue
hasActiveKey = true
// Token 已过期 / 即将过期:即使配额缓存还新,也触发一次后台刷新,
// 这样不会出现“打开抽屉看到已过期但没有任何刷新动作”的体验。
if (key.oauth_invalid_at == null && typeof key.oauth_expires_at === 'number') {
if ((key.oauth_expires_at - now) <= ANTIGRAVITY_AUTO_TOKEN_REFRESH_SKEW_SECONDS) {
return true
}
}
const meta: UpstreamMetadata | null | undefined = key.upstream_metadata
const updatedAt = meta?.antigravity?.updated_at
const quotaByModel = meta?.antigravity?.quota_by_model
// 只要有一个活跃 key 没有配额/为空/过期,就刷新一次(接口会批量刷新所有活跃 key
if (!quotaByModel || typeof quotaByModel !== 'object' || Object.keys(quotaByModel).length === 0) {
return true
}
if (typeof updatedAt !== 'number' || (now - updatedAt) > ANTIGRAVITY_AUTO_QUOTA_REFRESH_STALE_SECONDS) {
return true
}
}
return false
}
async function autoRefreshAntigravityQuotaInBackground() {
if (!props.providerId) return
if (provider.value?.provider_type !== 'antigravity') return
if (refreshingQuota.value) return
if (!shouldAutoRefreshAntigravityQuota()) return
const hadCachedQuota = allKeys.value.some(({ key }) => (
key.is_active &&
key.upstream_metadata &&
hasAntigravityQuotaData(key.upstream_metadata)
))
refreshingQuota.value = true
try {
const result = await refreshProviderQuota(props.providerId)
if (result.success > 0) {
// 重新加载 keys 以更新配额显示
await loadEndpoints()
} else if (!hadCachedQuota) {
showError('没有获取到配额信息请检查账号是否已授权、project_id 是否存在)', '提示')
}
} catch (err: any) {
if (!hadCachedQuota) {
showError(err.response?.data?.detail || '后台刷新配额失败', '错误')
}
} finally {
refreshingQuota.value = false
}
}
async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
antigravityQuotaDialogKey.value = key
antigravityQuotaDialogOpen.value = true
// 没有配额数据时主动获取
if (!key.upstream_metadata || !hasAntigravityQuotaData(key.upstream_metadata)) {
if (refreshingQuota.value) return
refreshingQuota.value = true
try {
const result = await refreshProviderQuota(props.providerId)
if (result.success > 0) {
await loadEndpoints()
// 更新弹窗引用的 key 数据
const updated = allKeys.value.find(({ key: k }) => k.id === key.id)
if (updated) {
antigravityQuotaDialogKey.value = updated.key
}
}
} catch {
// 静默失败,弹窗会显示"暂无配额数据"
} finally {
refreshingQuota.value = false
}
}
}
async function handleKeyChanged() {
await loadEndpoints()
// 并行刷新模型列表和模型映射(因为模型权限会影响正则映射预览)
@@ -1105,6 +1368,8 @@ async function handleKeyChanged() {
modelMappingTabRef.value?.reload()
])
emit('refresh')
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
void autoRefreshAntigravityQuotaInBackground()
}
// 切换密钥启用状态
@@ -1457,10 +1722,12 @@ function formatOAuthPlanType(planType: string): string {
plus: 'Plus',
pro: 'Pro',
free: 'Free',
paid: 'Paid',
team: 'Team',
enterprise: 'Enterprise',
ultra: 'Ultra',
}
return labels[planType] || planType
return labels[planType.toLowerCase()] || planType
}
// Codex 剩余额度样式(基于已用百分比计算剩余)
@@ -1480,13 +1747,160 @@ function getQuotaRemainingBarColor(usedPercent: number): string {
}
// 检查是否有 Codex 额度数据
function hasCodexQuotaData(metadata: any): boolean {
function hasCodexQuotaData(metadata: UpstreamMetadata | null | undefined): boolean {
if (!metadata) return false
return metadata.primary_used_percent !== undefined ||
metadata.secondary_used_percent !== undefined ||
(metadata.has_credits && metadata.credits_balance !== undefined)
}
interface AntigravityQuotaItem {
model: string
label: string
usedPercent: number
remainingPercent: number
resetSeconds: number | null
}
function hasAntigravityQuotaData(metadata: UpstreamMetadata | null | undefined): boolean {
const quotaByModel = metadata?.antigravity?.quota_by_model
return !!quotaByModel && typeof quotaByModel === 'object' && Object.keys(quotaByModel).length > 0
}
function formatAntigravityUpdatedAt(updatedAt: number): string {
if (!updatedAt || typeof updatedAt !== 'number') return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - updatedAt
if (diff <= 60) return '刚刚更新'
const minutes = Math.floor(diff / 60)
if (minutes < 60) return `${minutes}分钟前更新`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前更新`
const days = Math.floor(hours / 24)
return `${days}天前更新`
}
function secondsUntilReset(resetTime: string): number | null {
if (!resetTime) return null
const ts = Date.parse(resetTime)
if (Number.isNaN(ts)) return null
const diff = Math.floor((ts - Date.now()) / 1000)
return diff > 0 ? diff : 0
}
function getAntigravityQuotaItems(metadata: UpstreamMetadata | null | undefined): AntigravityQuotaItem[] {
const quotaByModel = metadata?.antigravity?.quota_by_model
if (!quotaByModel || typeof quotaByModel !== 'object') return []
const items: AntigravityQuotaItem[] = []
for (const [model, rawInfo] of Object.entries(quotaByModel)) {
if (!model) continue
const info: Partial<AntigravityModelQuota> = rawInfo || {}
let usedPercent = Number(info.used_percent)
if (!Number.isFinite(usedPercent)) {
const remainingFraction = Number(info.remaining_fraction)
if (Number.isFinite(remainingFraction)) {
usedPercent = (1 - remainingFraction) * 100
} else {
continue
}
}
if (usedPercent < 0) usedPercent = 0
if (usedPercent > 100) usedPercent = 100
const remainingPercent = Math.max(100 - usedPercent, 0)
let resetSeconds: number | null = null
if (typeof info.reset_time === 'string' && info.reset_time.trim()) {
resetSeconds = secondsUntilReset(info.reset_time.trim())
}
items.push({
model,
label: model,
usedPercent,
remainingPercent,
resetSeconds,
})
}
// 按“最紧张”(已用最多)优先排序,便于快速定位额度风险;完整列表通过滚动展示
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
return items
}
// Antigravity 配额分组定义(按匹配优先级排列,具体规则在前)
interface AntigravityQuotaGroup {
key: string
label: string
match: (model: string) => boolean
}
const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroup[] = [
{ key: 'claude-gpt', label: 'Claude 4.5', match: m => m.includes('claude') },
{ 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 {
key: string
label: string
usedPercent: number // 组内最高已用百分比(最紧张)
remainingPercent: number // 100 - usedPercent
resetSeconds: number | null
}
function getAntigravityQuotaSummary(metadata: UpstreamMetadata | null | undefined): AntigravityQuotaSummaryItem[] {
const items = getAntigravityQuotaItems(metadata)
if (!items.length) return []
// 将每个模型归入分组
const groupMap = new Map<string, { label: string, maxUsed: number, resetSeconds: number | null }>()
for (const item of items) {
const model = item.model.toLowerCase()
const group = ANTIGRAVITY_QUOTA_GROUPS.find(g => g.match(model))
if (!group) continue
const existing = groupMap.get(group.key)
if (!existing) {
groupMap.set(group.key, {
label: group.label,
maxUsed: item.usedPercent,
resetSeconds: item.resetSeconds,
})
} else {
if (item.usedPercent > existing.maxUsed) {
existing.maxUsed = item.usedPercent
}
if (existing.resetSeconds === null) {
existing.resetSeconds = item.resetSeconds
} else if (item.resetSeconds !== null && item.resetSeconds < existing.resetSeconds) {
existing.resetSeconds = item.resetSeconds
}
}
}
// 按 ANTIGRAVITY_QUOTA_GROUPS 定义的顺序输出
const result: AntigravityQuotaSummaryItem[] = []
for (const group of ANTIGRAVITY_QUOTA_GROUPS) {
const data = groupMap.get(group.key)
if (!data) continue
result.push({
key: group.key,
label: data.label,
usedPercent: data.maxUsed,
remainingPercent: Math.max(100 - data.maxUsed, 0),
resetSeconds: data.resetSeconds,
})
}
return result
}
// 格式化重置时间
function formatResetTime(seconds: number): string {
const days = Math.floor(seconds / 86400)
@@ -1508,10 +1922,12 @@ function getOAuthPlanTypeClass(planType: 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',
paid: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
team: 'border-purple-500/50 text-purple-600 dark:text-purple-400',
enterprise: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
}
return classes[planType] || ''
return classes[planType.toLowerCase()] || ''
}
// OAuth 状态信息(包括失效和过期)
@@ -1707,4 +2123,19 @@ useEscapeKey(() => {
.drawer-leave-from .relative {
transform: translateX(0);
}
/* 轻量滚动条(用于 Antigravity 模型配额等小区域) */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.2);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.4);
}
</style>

View File

@@ -31,14 +31,13 @@
<Label>提供商类型</Label>
<Select
v-model="form.provider_type"
v-model:open="providerTypeSelectOpen"
:disabled="isEditMode"
>
<SelectTrigger>
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
<!-- 新建模式允许自定义 Codex -->
<!-- 新建模式允许自定义Codex Antigravity -->
<template v-if="!isEditMode">
<SelectItem value="custom">
自定义
@@ -46,6 +45,9 @@
<SelectItem value="codex">
Codex
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
</template>
<!-- 编辑模式显示所有类型兼容已有数据 -->
<template v-else>
@@ -100,7 +102,6 @@
<Label>计费类型</Label>
<Select
v-model="form.billing_type"
v-model:open="billingTypeSelectOpen"
>
<SelectTrigger>
<SelectValue />
@@ -325,6 +326,7 @@ import { parseNumberInput } from '@/utils/form'
const props = defineProps<{
modelValue: boolean
provider?: ProviderWithEndpointsSummary | null // 编辑模式时传入
maxPriority?: number // 当前已有的最大优先级值
}>()
const emit = defineEmits<{
@@ -335,12 +337,18 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const loading = ref(false)
const providerTypeSelectOpen = ref(false)
const billingTypeSelectOpen = ref(false)
// 内部状态
const internalOpen = computed(() => props.modelValue)
// 计算新建时的默认优先级
const defaultPriority = computed(() => {
if (props.maxPriority != null) {
return Math.min(props.maxPriority + 10, 10000)
}
return 100
})
// 表单数据
const form = ref({
name: '',
@@ -353,7 +361,7 @@ const form = ref({
quota_reset_day: 30,
quota_last_reset_at: '', // 周期开始时间
quota_expires_at: '',
provider_priority: 999,
provider_priority: 100,
keep_priority_on_conversion: false, // 格式转换时是否保持优先级
// 状态配置
is_active: true,
@@ -383,7 +391,7 @@ function resetForm() {
quota_reset_day: 30,
quota_last_reset_at: '',
quota_expires_at: '',
provider_priority: 999,
provider_priority: defaultPriority.value,
keep_priority_on_conversion: false,
is_active: true,
rate_limit: undefined,

View File

@@ -18,7 +18,6 @@
>
<Label for="global-model">选择模型 *</Label>
<Select
v-model:open="globalModelSelectOpen"
:model-value="form.global_model_id"
:disabled="loadingGlobalModels"
@update:model-value="form.global_model_id = $event"
@@ -262,7 +261,6 @@ const showCache1h = computed(() => {
const submitting = ref(false)
const loadingGlobalModels = ref(false)
const availableGlobalModels = ref<GlobalModelResponse[]>([])
const globalModelSelectOpen = ref(false)
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)

View File

@@ -24,7 +24,7 @@
<!-- 用户筛选仅管理员可见 -->
<Select
v-if="isAdmin && availableUsers.length > 0"
v-model:open="filterUserSelectOpen"
:model-value="filterUser"
@update:model-value="$emit('update:filterUser', $event)"
>
@@ -47,7 +47,7 @@
<!-- 模型筛选 -->
<Select
v-model:open="filterModelSelectOpen"
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
>
@@ -71,7 +71,7 @@
<!-- 提供商筛选仅管理员可见 -->
<Select
v-if="isAdmin"
v-model:open="filterProviderSelectOpen"
:model-value="filterProvider"
@update:model-value="$emit('update:filterProvider', $event)"
>
@@ -94,7 +94,7 @@
<!-- API格式筛选 -->
<Select
v-model:open="filterApiFormatSelectOpen"
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
@@ -117,7 +117,7 @@
<!-- 状态筛选 -->
<Select
v-model:open="filterStatusSelectOpen"
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
@@ -725,13 +725,6 @@ const AVAILABLE_API_FORMATS = [
{ value: 'gemini:video', label: 'Gemini Video' },
] as const
// Select 打开状态
const filterUserSelectOpen = ref(false)
const filterModelSelectOpen = ref(false)
const filterProviderSelectOpen = ref(false)
const filterApiFormatSelectOpen = ref(false)
const filterStatusSelectOpen = ref(false)
// 使用模块级常量
const availableApiFormats = AVAILABLE_API_FORMATS

View File

@@ -182,7 +182,6 @@
<div class="flex items-center gap-3">
<Select
v-model="form.role"
v-model:open="roleSelectOpen"
class="flex-1"
>
<SelectTrigger
@@ -405,7 +404,6 @@ const isOpen = computed(() => props.open)
const saving = ref(false)
const formNonce = ref(createFieldNonce())
const passwordFocused = ref(false)
const roleSelectOpen = ref(false)
// 下拉框状态
const providerDropdownOpen = ref(false)

View File

@@ -39,7 +39,6 @@
<!-- 状态筛选 -->
<Select
v-model="filterStatus"
v-model:open="filterStatusOpen"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
@@ -58,7 +57,6 @@
<!-- 余额类型筛选 -->
<Select
v-model="filterBalance"
v-model:open="filterBalanceOpen"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部类型" />
@@ -759,9 +757,7 @@ const EXPIRY_SOON_DAYS = 7
// 筛选相关
const searchQuery = ref('')
const filterStatus = ref<'all' | 'active' | 'inactive'>('all')
const filterStatusOpen = ref(false)
const filterBalance = ref<'all' | 'limited' | 'unlimited'>('all')
const filterBalanceOpen = ref(false)
const statusFilters = [
{ value: 'all' as const, label: '全部状态' },

View File

@@ -93,7 +93,6 @@
<div class="flex items-center gap-2">
<!-- 状态筛选 -->
<Select
v-model:open="statusSelectOpen"
v-model="filterStatus"
>
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
@@ -895,7 +894,6 @@ const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const filterStatus = ref('all')
const statusSelectOpen = ref(false)
const filterModel = ref('')
const showDetail = ref(false)
const selectedTask = ref<AsyncTaskDetail | null>(null)

View File

@@ -33,7 +33,6 @@
<!-- 事件类型筛选 -->
<Select
v-model="filters.eventType"
v-model:open="eventTypeSelectOpen"
@update:model-value="handleEventTypeChange"
>
<SelectTrigger class="w-24 sm:w-40 h-8 border-border/60">
@@ -78,7 +77,6 @@
<!-- 时间范围筛选 -->
<Select
v-model="filtersDaysString"
v-model:open="daysSelectOpen"
@update:model-value="handleDaysChange"
>
<SelectTrigger class="w-20 sm:w-28 h-8 border-border/60">
@@ -468,9 +466,6 @@ const selectedLog = ref<AuditLog | null>(null)
// 搜索查询
const searchQuery = ref('')
// Select open state
const eventTypeSelectOpen = ref(false)
const daysSelectOpen = ref(false)
const filters = ref({
username: '',

View File

@@ -46,7 +46,6 @@ const clearingRowAffinityKey = ref<string | null>(null)
const currentPage = ref(1)
const pageSize = ref(20)
const currentTime = ref(Math.floor(Date.now() / 1000))
const analysisHoursSelectOpen = ref(false)
// ==================== 模型映射缓存 ====================
@@ -1060,7 +1059,6 @@ onBeforeUnmount(() => {
<div class="flex flex-wrap items-center gap-2">
<Select
v-model="analysisHours"
v-model:open="analysisHoursSelectOpen"
>
<SelectTrigger class="w-24 sm:w-28 h-8">
<SelectValue placeholder="时间段" />

View File

@@ -159,7 +159,6 @@
</Label>
<Select
v-model="smtpEncryption"
v-model:open="smtpEncryptionSelectOpen"
>
<SelectTrigger
id="smtp-encryption"
@@ -239,7 +238,6 @@
</Label>
<Select
v-model="emailConfig.email_suffix_mode"
v-model:open="emailSuffixModeSelectOpen"
:disabled="!requireEmailVerification"
>
<SelectTrigger
@@ -509,8 +507,6 @@ interface EmailConfig {
const smtpSaveLoading = ref(false)
const emailVerificationSaveLoading = ref(false)
const smtpEncryptionSelectOpen = ref(false)
const emailSuffixModeSelectOpen = ref(false)
const testSmtpLoading = ref(false)
const smtpPasswordIsSet = ref(false)
const requireEmailVerification = ref(false) // 是否开启了邮箱验证

View File

@@ -565,6 +565,7 @@
<ProviderFormDialog
v-model="providerDialogOpen"
:provider="providerToEdit"
:max-priority="maxProviderPriority"
@provider-created="handleProviderAdded"
@provider-updated="handleProviderUpdated"
/>
@@ -672,6 +673,13 @@ const priorityModeConfig = computed(() => {
}
})
// 当前已有提供商的最大优先级
const maxProviderPriority = computed(() => {
if (providers.value.length === 0) return undefined
const priorities = providers.value.map(p => p.provider_priority).filter(v => typeof v === 'number' && Number.isFinite(v))
return priorities.length > 0 ? Math.max(...priorities) : undefined
})
// 筛选后的提供商列表
const filteredProviders = computed(() => {
let result = [...providers.value]

View File

@@ -234,7 +234,6 @@
</Label>
<Select
v-model="systemConfig.request_record_level"
v-model:open="logLevelSelectOpen"
>
<SelectTrigger
id="request-log-level"
@@ -539,7 +538,6 @@
>
<Clock class="w-4 h-4 text-muted-foreground" />
<Select
v-model:open="task.hourSelectOpen.value"
:model-value="task.hour"
@update:model-value="(val: string) => task.updateTime(val, task.minute)"
>
@@ -558,7 +556,6 @@
</Select>
<span class="text-sm text-muted-foreground">:</span>
<Select
v-model:open="task.minuteSelectOpen.value"
:model-value="task.minute"
@update:model-value="(val: string) => task.updateTime(task.hour, val)"
>
@@ -1050,7 +1047,6 @@ interface SystemConfig {
const basicConfigLoading = ref(false)
const logConfigLoading = ref(false)
const cleanupConfigLoading = ref(false)
const logLevelSelectOpen = ref(false)
// 导出/导入相关
const exportLoading = ref(false)
@@ -1382,8 +1378,6 @@ async function handleProviderCheckinToggle(enabled: boolean) {
const previousCheckinTime = ref('')
const checkinConfigLoading = ref(false)
const quotaResetConfigLoading = ref(false)
const checkinHourSelectOpen = ref(false)
const checkinMinuteSelectOpen = ref(false)
// 解析签到时间的小时和分钟
const checkinHour = computed(() => {
@@ -1444,14 +1438,8 @@ async function handleOAuthTokenRefreshToggle(enabled: boolean) {
}
}
// OAuth Token 刷新(无时间配置,占位 ref
const oauthRefreshHourSelectOpen = ref(false)
const oauthRefreshMinuteSelectOpen = ref(false)
// 用户配额重置时间相关
const previousUserQuotaResetTime = ref('')
const userQuotaResetHourSelectOpen = ref(false)
const userQuotaResetMinuteSelectOpen = ref(false)
const previousUserQuotaResetIntervalDays = ref(1)
const userQuotaResetHour = computed(() => {
@@ -1493,8 +1481,6 @@ const scheduledTasks = computed(() => [
hasTimeConfig: true,
hour: checkinHour.value,
minute: checkinMinute.value,
hourSelectOpen: checkinHourSelectOpen,
minuteSelectOpen: checkinMinuteSelectOpen,
updateTime: updateCheckinTime,
hasChanges: hasCheckinTimeChanged.value,
loading: checkinConfigLoading.value,
@@ -1510,8 +1496,6 @@ const scheduledTasks = computed(() => [
hasTimeConfig: true,
hour: userQuotaResetHour.value,
minute: userQuotaResetMinute.value,
hourSelectOpen: userQuotaResetHourSelectOpen,
minuteSelectOpen: userQuotaResetMinuteSelectOpen,
updateTime: updateUserQuotaResetTime,
hasChanges: hasQuotaResetConfigChanged.value,
loading: quotaResetConfigLoading.value,
@@ -1527,8 +1511,6 @@ const scheduledTasks = computed(() => [
hasTimeConfig: false,
hour: '',
minute: '',
hourSelectOpen: oauthRefreshHourSelectOpen,
minuteSelectOpen: oauthRefreshMinuteSelectOpen,
updateTime: () => {},
hasChanges: false,
loading: false,

View File

@@ -15,7 +15,6 @@
:allow-hourly="true"
/>
<Select
v-model:open="userSelectOpen"
v-model="selectedUserId"
>
<SelectTrigger class="h-8 text-xs w-52">
@@ -32,7 +31,6 @@
</SelectContent>
</Select>
<Select
v-model:open="compareUserSelectOpen"
v-model="compareUserId"
>
<SelectTrigger class="h-8 text-xs w-52">
@@ -164,8 +162,6 @@ const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const users = ref<User[]>([])
const selectedUserId = ref<string | null>(null)
const compareUserId = ref<string>('__none__')
const userSelectOpen = ref(false)
const compareUserSelectOpen = ref(false)
const leaderboard = ref<LeaderboardItem[]>([])
const leaderboardLoading = ref(false)

View File

@@ -45,7 +45,6 @@
</div>
<Select
v-model="filterRole"
v-model:open="filterRoleOpenMobile"
>
<SelectTrigger class="w-24 h-8 text-xs border-border/60">
<SelectValue placeholder="角色" />
@@ -64,7 +63,6 @@
</Select>
<Select
v-model="filterStatus"
v-model:open="filterStatusOpenMobile"
>
<SelectTrigger class="w-20 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
@@ -110,7 +108,6 @@
<!-- 角色筛选 -->
<Select
v-model="filterRole"
v-model:open="filterRoleOpen"
>
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="全部角色" />
@@ -131,7 +128,6 @@
<!-- 状态筛选 -->
<Select
v-model="filterStatus"
v-model:open="filterStatusOpen"
>
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
@@ -799,10 +795,6 @@ const loadingStats = ref(false)
const searchQuery = ref('')
const filterRole = ref('all')
const filterStatus = ref('all')
const filterRoleOpen = ref(false)
const filterStatusOpen = ref(false)
const filterRoleOpenMobile = ref(false)
const filterStatusOpenMobile = ref(false)
const currentPage = ref(1)
const pageSize = ref(20)

View File

@@ -379,7 +379,6 @@
>类型</Label>
<Select
v-model="formData.type"
v-model:open="typeSelectOpen"
>
<SelectTrigger
id="type"
@@ -597,7 +596,6 @@ const pageSize = ref(20)
const dialogOpen = ref(false)
const deleteDialogOpen = ref(false)
const detailDialogOpen = ref(false)
const typeSelectOpen = ref(false)
const editingAnnouncement = ref<Announcement | null>(null)
const deletingAnnouncement = ref<Announcement | null>(null)
const viewingAnnouncement = ref<Announcement | null>(null)