mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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
|
||||
}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>>({})
|
||||
|
||||
@@ -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()
|
||||
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
||||
// (不 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '全部状态' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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="时间段" />
|
||||
|
||||
@@ -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) // 是否开启了邮箱验证
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user