mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
Retry pre-response transport failures across candidates with an explicit stop policy, and propagate end-to-end timing into usage records and UI diagnostics. Remove legacy body, import, cookie, PII, and tunnel replay caps while preserving optional operator-configured gateway limits.
3966 lines
157 KiB
Vue
3966 lines
157 KiB
Vue
<template>
|
||
<!-- 自定义抽屉 -->
|
||
<Teleport to="body">
|
||
<Transition
|
||
name="drawer"
|
||
appear
|
||
>
|
||
<div
|
||
v-if="open && (loading || provider)"
|
||
class="fixed inset-0 z-50 flex justify-end"
|
||
@click.self="handleBackdropClick"
|
||
>
|
||
<!-- 背景遮罩 -->
|
||
<div
|
||
class="absolute inset-0 bg-black/30"
|
||
@click="handleBackdropClick"
|
||
/>
|
||
|
||
<!-- 抽屉内容 -->
|
||
<Card class="drawer-panel relative h-full w-full sm:w-[700px] sm:max-w-[90vw] rounded-none shadow-2xl overflow-y-auto">
|
||
<!-- 加载状态 -->
|
||
<div
|
||
v-if="loading"
|
||
class="flex items-center justify-center py-12"
|
||
>
|
||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||
</div>
|
||
|
||
<template v-else-if="provider">
|
||
<ProviderDetailHeader
|
||
v-model:provider-proxy-popover-open="providerProxyPopoverOpen"
|
||
:provider="provider"
|
||
:endpoints="endpoints"
|
||
:loading-provider-endpoints="loadingProviderEndpoints"
|
||
:system-format-conversion-enabled="systemFormatConversionEnabled"
|
||
:has-failover-rules="hasFailoverRules"
|
||
:provider-proxy-node-name="getProviderProxyNodeName()"
|
||
:saving-provider-proxy="savingProviderProxy"
|
||
@toggle-format-conversion="toggleFormatConversion"
|
||
@open-failover-rules="failoverRulesDialogOpen = true"
|
||
@set-provider-proxy="setProviderProxy"
|
||
@clear-provider-proxy="clearProviderProxy"
|
||
@edit="$emit('edit', $event)"
|
||
@toggle-status="$emit('toggleStatus', $event)"
|
||
@close="handleClose"
|
||
@edit-endpoint="handleEditEndpoint"
|
||
@add-endpoint="showAddEndpointDialog"
|
||
/>
|
||
|
||
<div class="space-y-6 p-4 sm:p-6">
|
||
<!-- 配额使用情况 -->
|
||
<ProviderMonthlyQuotaCard
|
||
v-if="provider.billing_type === 'monthly_quota' && provider.monthly_quota_usd"
|
||
:used="provider.monthly_used_usd"
|
||
:quota="provider.monthly_quota_usd"
|
||
:reset-day="provider.quota_reset_day"
|
||
/>
|
||
|
||
<!-- 密钥管理 -->
|
||
<Card class="overflow-hidden">
|
||
<div class="p-4 border-b border-border/60">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-sm font-semibold">
|
||
{{ legacyT(isKeyManagedProviderType(provider.provider_type) ? '密钥管理' : '账号管理') }}
|
||
</h3>
|
||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||
<Button
|
||
v-if="endpoints.length > 0 && provider.provider_type === 'custom'"
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-9"
|
||
@click="keyBatchImportDialogOpen = true"
|
||
>
|
||
<ListPlus class="mr-1.5 h-3.5 w-3.5" />
|
||
批量导入
|
||
</Button>
|
||
<Button
|
||
v-if="endpoints.length > 0"
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-9"
|
||
@click="handleAddKeyToFirstEndpoint"
|
||
>
|
||
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
||
{{ legacyT(isKeyManagedProviderType(provider.provider_type) ? '添加密钥' : '添加账号') }}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 密钥列表 -->
|
||
<div
|
||
v-if="loadingProviderKeys && allKeys.length === 0"
|
||
class="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground"
|
||
>
|
||
<Loader2 class="w-4 h-4 animate-spin" />
|
||
{{ legacyT('正在加载') }}{{ legacyT(isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号') }}
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="allKeys.length > 0"
|
||
class="divide-y divide-border/40"
|
||
:class="shouldPaginateKeys && 'flex flex-col'"
|
||
>
|
||
<div
|
||
v-for="({ key, endpoint }, localIdx) in paginatedKeys"
|
||
:key="key.id"
|
||
class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item"
|
||
:class="{
|
||
'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === getGlobalKeyIndex(localIdx),
|
||
'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === getGlobalKeyIndex(localIdx) && keyDragState.isDragging,
|
||
'opacity-40 bg-muted/20': !key.is_active
|
||
}"
|
||
draggable="true"
|
||
@dragstart="handleKeyDragStart($event, getGlobalKeyIndex(localIdx))"
|
||
@dragend="handleKeyDragEnd"
|
||
@dragover="handleKeyDragOver($event, getGlobalKeyIndex(localIdx))"
|
||
@dragleave="handleKeyDragLeave"
|
||
@drop="handleKeyDrop($event, getGlobalKeyIndex(localIdx))"
|
||
>
|
||
<!-- 第一行:名称 + 状态 + 操作按钮 -->
|
||
<div class="flex items-center justify-between gap-2">
|
||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||
<!-- 拖拽手柄 -->
|
||
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover/item:text-muted-foreground transition-colors shrink-0">
|
||
<GripVertical class="w-4 h-4" />
|
||
</div>
|
||
<ProviderKeyIdentityBlock
|
||
:api-key="key"
|
||
:masked-secret-label="getProviderMaskedSecretLabel(key, provider.provider_type)"
|
||
:oauth-plan-label="key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : null"
|
||
:oauth-plan-class="key.oauth_plan_type ? getOAuthPlanTypeClass(key.oauth_plan_type) : ''"
|
||
:oauth-org-badge="getOAuthOrgBadge(key)"
|
||
:kiro-subscription-label="shouldShowKiroSubscriptionBadge(key) ? getKiroSubscriptionBadgeLabel(key) : null"
|
||
:kiro-subscription-class="shouldShowKiroSubscriptionBadge(key) ? getOAuthPlanTypeClass(getKiroSubscriptionBadgeLabel(key)) : ''"
|
||
:can-export-credential="canExportOAuthCredential(key)"
|
||
:show-o-auth-refresh-control="shouldShowOAuthRefreshControl(key, provider.provider_type)"
|
||
:account-level-block="isAccountLevelBlock(key)"
|
||
:oauth-status="getKeyOAuthExpires(key)"
|
||
:oauth-status-title="getOAuthStatusTitle(key)"
|
||
:oauth-refresh-button-title="getOAuthRefreshButtonTitle(key)"
|
||
:can-refresh-credential="canRefreshOAuthCredential(key)"
|
||
:clearing-o-auth-invalid="clearingOAuthInvalidKeyId === key.id"
|
||
:refreshing-o-auth="refreshingOAuthKeyId === key.id"
|
||
:antigravity-inactive="provider.provider_type === 'antigravity' && key.is_active && isOAuthManagedCredential(key) && !hasAntigravityQuotaDisplayData(key)"
|
||
@copy-name="copyToClipboard"
|
||
@download-credential="downloadRefreshToken(key)"
|
||
@copy-full-key="copyFullKey(key)"
|
||
@clear-o-auth-invalid="handleClearOAuthInvalid(key)"
|
||
@refresh-o-auth="handleRefreshOAuth(key)"
|
||
/>
|
||
</div>
|
||
<ProviderKeyActionCluster
|
||
:api-key="key"
|
||
:provider-type="provider.provider_type"
|
||
:recoverable="isKeyRecoverable(key)"
|
||
:recover-title="getRecoverKeyTitle(key)"
|
||
:circuit-breaker-title="getKeyCircuitBreakerTitle(key)"
|
||
:circuit-probe-countdown="getKeyCircuitProbeCountdown(key)"
|
||
:health-score-bar-class="getHealthScoreBarColor(key.health_score || 0)"
|
||
:health-score-text-class="getHealthScoreColor(key.health_score || 0)"
|
||
:proxy-popover-open="proxyPopoverOpenKeyId === key.id"
|
||
:proxy-node-name="getKeyProxyNodeName(key)"
|
||
:saving-proxy="savingProxyKeyId === key.id"
|
||
:toggling="togglingKeyId === key.id"
|
||
@recover="handleRecoverKey(key)"
|
||
@permissions="handleKeyPermissions(key)"
|
||
@update:proxy-popover-open="(v: boolean) => handleProxyPopoverToggle(key.id, v)"
|
||
@clear-proxy="clearKeyProxy(key)"
|
||
@set-proxy="(v: string) => setKeyProxy(key, v)"
|
||
@edit="handleEditKey(endpoint, key)"
|
||
@open-antigravity-quota="openAntigravityQuotaDialog(key)"
|
||
@toggle-active="toggleKeyActive(key)"
|
||
@delete="handleDeleteKey(key)"
|
||
/>
|
||
</div>
|
||
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
|
||
<div
|
||
v-if="hasCodexQuotaDisplayData(key)"
|
||
class="mt-2 p-2 bg-muted/30 rounded-md"
|
||
>
|
||
<ProviderQuotaSectionHeader
|
||
:title="legacyT('账号配额')"
|
||
:loading="refreshingQuota"
|
||
:updated-text="getCodexQuotaDisplay(key)?.updated_at ? formatCodexUpdatedAt(getCodexQuotaDisplay(key)?.updated_at || 0) : null"
|
||
/>
|
||
<!-- 普通 Codex 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
||
<div
|
||
class="grid gap-3"
|
||
:class="isCodexTeamPlan(key) ? 'grid-cols-2' : 'grid-cols-1'"
|
||
>
|
||
<!-- 主限额 -->
|
||
<ProviderQuotaProgressRow
|
||
v-if="getCodexQuotaDisplay(key)?.primary_used_percent !== undefined"
|
||
:label="legacyT(getCodexPrimaryQuotaLabel(key))"
|
||
:used-percent="getCodexQuotaDisplay(key)?.primary_used_percent || 0"
|
||
:remaining-percent="toCodexRemainingPercent(getCodexQuotaDisplay(key)?.primary_used_percent)"
|
||
:meter-class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.primary_used_percent || 0)"
|
||
:bar-class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.primary_used_percent || 0)"
|
||
:reset-text="(getCodexQuotaDisplay(key)?.primary_reset_at || getCodexQuotaDisplay(key)?.primary_reset_seconds) && shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.primary_used_percent || 0)
|
||
? getResetCountdownText(
|
||
getCodexQuotaDisplay(key)?.primary_reset_at,
|
||
getCodexQuotaDisplay(key)?.primary_reset_seconds,
|
||
getCodexQuotaDisplay(key)?.updated_at,
|
||
getCodexQuotaDisplay(key)?.primary_used_percent
|
||
)
|
||
: null"
|
||
:footer-class="getResetCountdownClass(
|
||
getCodexQuotaDisplay(key)?.primary_reset_at,
|
||
getCodexQuotaDisplay(key)?.primary_reset_seconds,
|
||
getCodexQuotaDisplay(key)?.updated_at,
|
||
getCodexQuotaDisplay(key)?.primary_used_percent
|
||
)"
|
||
/>
|
||
<!-- 5H限额(仅 Team/Plus/Enterprise 显示) -->
|
||
<ProviderQuotaProgressRow
|
||
v-if="isCodexTeamPlan(key) && getCodexQuotaDisplay(key)?.secondary_used_percent !== undefined"
|
||
:label="legacyT('5H限额')"
|
||
:used-percent="getCodexQuotaDisplay(key)?.secondary_used_percent || 0"
|
||
:remaining-percent="toCodexRemainingPercent(getCodexQuotaDisplay(key)?.secondary_used_percent)"
|
||
:meter-class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)"
|
||
:bar-class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)"
|
||
:reset-text="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)
|
||
? getCodexQuotaResetText(
|
||
getCodexQuotaDisplay(key)?.secondary_reset_at,
|
||
getCodexQuotaDisplay(key)?.secondary_reset_seconds,
|
||
getCodexQuotaDisplay(key)?.updated_at,
|
||
getCodexQuotaDisplay(key)?.secondary_used_percent
|
||
)
|
||
: null"
|
||
:footer-class="getResetCountdownClass(
|
||
getCodexQuotaDisplay(key)?.secondary_reset_at,
|
||
getCodexQuotaDisplay(key)?.secondary_reset_seconds,
|
||
getCodexQuotaDisplay(key)?.updated_at,
|
||
getCodexQuotaDisplay(key)?.secondary_used_percent
|
||
)"
|
||
/>
|
||
</div>
|
||
<!-- Spark 限额独立一行展示,避免与普通 Codex 周/5H 混淆 -->
|
||
<div
|
||
v-if="hasCodexSparkQuotaDisplayData(key)"
|
||
class="mt-3 border-t border-border/60 pt-2"
|
||
>
|
||
<div class="mb-1 text-[10px] text-muted-foreground">
|
||
GPT-5.3 Codex Spark
|
||
</div>
|
||
<div class="grid gap-3 grid-cols-2">
|
||
<ProviderQuotaProgressRow
|
||
v-if="getCodexQuotaDisplay(key)?.spark_secondary_used_percent !== undefined"
|
||
:label="legacyT('Spark 周')"
|
||
:used-percent="getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0"
|
||
:remaining-percent="toCodexRemainingPercent(getCodexQuotaDisplay(key)?.spark_secondary_used_percent)"
|
||
:meter-class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)"
|
||
:bar-class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)"
|
||
:reset-text="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)
|
||
? getCodexQuotaResetText(
|
||
getCodexQuotaDisplay(key)?.spark_secondary_reset_at,
|
||
getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds,
|
||
getCodexQuotaDisplay(key)?.updated_at,
|
||
getCodexQuotaDisplay(key)?.spark_secondary_used_percent
|
||
)
|
||
: null"
|
||
:footer-class="getResetCountdownClass(
|
||
getCodexQuotaDisplay(key)?.spark_secondary_reset_at,
|
||
getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds,
|
||
getCodexQuotaDisplay(key)?.updated_at,
|
||
getCodexQuotaDisplay(key)?.spark_secondary_used_percent
|
||
)"
|
||
/>
|
||
<ProviderQuotaProgressRow
|
||
v-if="getCodexQuotaDisplay(key)?.spark_primary_used_percent !== undefined"
|
||
:label="legacyT('Spark 5H')"
|
||
:used-percent="getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0"
|
||
:remaining-percent="toCodexRemainingPercent(getCodexQuotaDisplay(key)?.spark_primary_used_percent)"
|
||
:meter-class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)"
|
||
:bar-class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)"
|
||
:reset-text="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)
|
||
? getCodexQuotaResetText(
|
||
getCodexQuotaDisplay(key)?.spark_primary_reset_at,
|
||
getCodexQuotaDisplay(key)?.spark_primary_reset_seconds,
|
||
getCodexQuotaDisplay(key)?.updated_at,
|
||
getCodexQuotaDisplay(key)?.spark_primary_used_percent
|
||
)
|
||
: null"
|
||
:footer-class="getResetCountdownClass(
|
||
getCodexQuotaDisplay(key)?.spark_primary_reset_at,
|
||
getCodexQuotaDisplay(key)?.spark_primary_reset_seconds,
|
||
getCodexQuotaDisplay(key)?.updated_at,
|
||
getCodexQuotaDisplay(key)?.spark_primary_used_percent
|
||
)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="getCodexResetCreditAvailableCount(key) !== null"
|
||
class="mt-3 border-t border-border/60 pt-2"
|
||
>
|
||
<div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-[10px] leading-4 text-muted-foreground">
|
||
<button
|
||
v-if="canConsumeCodexResetCredit(key)"
|
||
type="button"
|
||
class="font-medium text-primary underline-offset-2 transition-colors hover:text-primary/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-60"
|
||
:disabled="consumingCodexResetCreditKeyId === key.id"
|
||
@click="handleConsumeCodexResetCredit(key)"
|
||
>
|
||
{{ consumingCodexResetCreditKeyId === key.id ? legacyT('重置中...') : legacyT('点击以进行重置') }}
|
||
</button>
|
||
<span
|
||
v-else
|
||
class="font-medium"
|
||
>
|
||
{{ legacyT('点击以进行重置') }}
|
||
</span>
|
||
<span>{{ formatCodexResetCreditCount(key) }}</span>
|
||
<template v-if="getVisibleCodexResetCreditItems(key).length > 0">
|
||
<span aria-hidden="true">|</span>
|
||
<span>{{ legacyT('临近过期') }}</span>
|
||
<template
|
||
v-for="(item, itemIndex) in getVisibleCodexResetCreditItems(key)"
|
||
:key="item.id || `${item.displayKey}-${item.expiresAt}`"
|
||
>
|
||
<span
|
||
:title="item.title"
|
||
class="tabular-nums"
|
||
>
|
||
{{ item.displayKey }} {{ formatCodexResetCreditExpiresAt(item.expiresAt) }}
|
||
</span>
|
||
<span
|
||
v-if="itemIndex < getVisibleCodexResetCreditItems(key).length - 1"
|
||
aria-hidden="true"
|
||
>·</span>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
||
<div
|
||
v-if="provider.provider_type === 'antigravity' && (hasAntigravityQuotaDisplayData(key) || isAntigravityForbiddenKey(key))"
|
||
class="mt-2 p-2 rounded-md"
|
||
:class="isAntigravityForbiddenKey(key) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||
>
|
||
<!-- 封禁状态显示 -->
|
||
<div
|
||
v-if="isAntigravityForbiddenKey(key)"
|
||
class="flex items-center gap-2 text-destructive"
|
||
>
|
||
<ShieldX class="w-4 h-4 shrink-0" />
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-[11px] font-medium">
|
||
{{ legacyT('账户访问被禁止') }}
|
||
</div>
|
||
<div
|
||
v-if="getAntigravityForbiddenReason(key)"
|
||
class="text-[10px] text-destructive/80 truncate"
|
||
:title="getAntigravityForbiddenReason(key)"
|
||
>
|
||
{{ getAntigravityForbiddenReason(key) }}
|
||
</div>
|
||
</div>
|
||
<span
|
||
v-if="getAntigravityForbiddenAt(key)"
|
||
class="text-[9px] text-destructive/60 shrink-0"
|
||
>
|
||
{{ formatBanTimestamp(getAntigravityForbiddenAt(key)) }}
|
||
</span>
|
||
</div>
|
||
<!-- 正常配额显示 -->
|
||
<template v-else>
|
||
<ProviderQuotaSectionHeader
|
||
:title="legacyT('模型配额')"
|
||
:loading="refreshingQuota"
|
||
:updated-text="getAntigravityQuotaUpdatedAt(key) ? formatAntigravityUpdatedAt(getAntigravityQuotaUpdatedAt(key) || 0) : null"
|
||
/>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<ProviderQuotaProgressRow
|
||
v-for="item in getAntigravityQuotaPreviewForKey(key)"
|
||
:key="item.model"
|
||
:label="item.label"
|
||
:title="item.model"
|
||
:used-percent="item.usedPercent"
|
||
:remaining-percent="item.remainingPercent"
|
||
:meter-class="getQuotaRemainingClass(item.usedPercent)"
|
||
:bar-class="getQuotaRemainingBarColor(item.usedPercent)"
|
||
>
|
||
<template #footer>
|
||
<div class="mt-0.5 space-y-0.5">
|
||
<div
|
||
v-if="item.resetSeconds !== null"
|
||
class="text-[9px] text-muted-foreground/70"
|
||
>
|
||
<template v-if="item.resetSeconds !== null && item.resetSeconds > 0">
|
||
{{ formatResetTime(item.resetSeconds) }}{{ legacyT('后重置') }}
|
||
</template>
|
||
<template v-else-if="item.resetSeconds !== null && item.resetSeconds <= 0">
|
||
{{ legacyT('已重置') }}
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</ProviderQuotaProgressRow>
|
||
<button
|
||
v-if="getAntigravityQuotaHiddenCountForKey(key) > 0"
|
||
type="button"
|
||
class="col-span-2 text-left text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||
@click="openAntigravityQuotaDialog(key)"
|
||
>
|
||
{{ legacyT('另有') }} {{ getAntigravityQuotaHiddenCountForKey(key) }} {{ legacyT('个模型,查看全部') }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<!-- Gemini CLI 上游模型配额 -->
|
||
<div
|
||
v-if="provider.provider_type === 'gemini_cli' && hasGeminiCliQuotaDisplayData(key)"
|
||
class="mt-2 p-2 rounded-md bg-muted/30"
|
||
>
|
||
<ProviderQuotaSectionHeader
|
||
:title="legacyT('模型配额')"
|
||
:loading="refreshingQuota"
|
||
:updated-text="getGeminiCliQuotaUpdatedAt(key) ? formatUpdatedAt(getGeminiCliQuotaUpdatedAt(key) || 0) : null"
|
||
/>
|
||
<div
|
||
v-if="getGeminiCliAccountCreditsText(key, 'gemini_cli')"
|
||
class="mb-2 text-[10px] font-medium text-foreground/90"
|
||
>
|
||
{{ getGeminiCliAccountCreditsText(key, 'gemini_cli') }}
|
||
</div>
|
||
<div
|
||
v-if="getGeminiCliQuotaItems(key).length > 0"
|
||
class="grid grid-cols-2 gap-3"
|
||
>
|
||
<ProviderQuotaProgressRow
|
||
v-for="item in getGeminiCliQuotaItems(key)"
|
||
:key="item.model"
|
||
:label="item.label"
|
||
:title="item.model"
|
||
:used-percent="item.usedPercent"
|
||
:remaining-percent="item.remainingPercent"
|
||
:meter-class="getQuotaRemainingClass(item.usedPercent)"
|
||
:bar-class="getQuotaRemainingBarColor(item.usedPercent)"
|
||
>
|
||
<template #footer>
|
||
<div
|
||
v-if="item.resetSeconds !== null && item.remainingPercent < 100"
|
||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||
>
|
||
<template v-if="item.resetSeconds > 0">
|
||
{{ formatResetTime(item.resetSeconds) }}{{ legacyT('后重置') }}
|
||
</template>
|
||
<template v-else>
|
||
{{ legacyT('已重置') }}
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</ProviderQuotaProgressRow>
|
||
</div>
|
||
</div>
|
||
<!-- Kiro 上游额度信息(仅当有元数据时显示) -->
|
||
<div
|
||
v-if="provider.provider_type === 'kiro' && (hasKiroQuotaDisplayData(key) || isKiroBannedKey(key))"
|
||
class="mt-2 p-2 rounded-md"
|
||
:class="isKiroBannedKey(key) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||
>
|
||
<!-- 封禁状态显示 -->
|
||
<div
|
||
v-if="isKiroBannedKey(key)"
|
||
class="flex items-center gap-2 text-destructive"
|
||
>
|
||
<ShieldX class="w-4 h-4 shrink-0" />
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-[11px] font-medium">
|
||
{{ legacyT('账户已封禁') }}
|
||
</div>
|
||
<div
|
||
v-if="getKiroQuotaDisplay(key)?.ban_reason"
|
||
class="text-[10px] text-destructive/80 truncate"
|
||
:title="getKiroQuotaDisplay(key)?.ban_reason"
|
||
>
|
||
{{ getKiroQuotaDisplay(key)?.ban_reason }}
|
||
</div>
|
||
</div>
|
||
<span
|
||
v-if="getKiroQuotaDisplay(key)?.banned_at"
|
||
class="text-[9px] text-destructive/60 shrink-0"
|
||
>
|
||
{{ formatBanTimestamp(getKiroQuotaDisplay(key)?.banned_at) }}
|
||
</span>
|
||
</div>
|
||
<!-- 正常配额显示 -->
|
||
<template v-else>
|
||
<ProviderQuotaSectionHeader
|
||
:title="legacyT('账号配额')"
|
||
:loading="refreshingQuota"
|
||
:updated-text="getKiroQuotaDisplay(key)?.updated_at ? formatKiroUpdatedAt(getKiroQuotaDisplay(key)?.updated_at || 0) : null"
|
||
/>
|
||
<!-- Kiro 额度显示:使用进度 -->
|
||
<div>
|
||
<!-- 使用额度进度条 -->
|
||
<ProviderQuotaProgressRow
|
||
:label="legacyT('使用额度')"
|
||
:used-percent="getKiroQuotaDisplay(key)?.usage_percentage || 0"
|
||
:meter-class="getQuotaRemainingClass(getKiroQuotaDisplay(key)?.usage_percentage || 0)"
|
||
:bar-class="getQuotaRemainingBarColor(getKiroQuotaDisplay(key)?.usage_percentage || 0)"
|
||
>
|
||
<template #footer>
|
||
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
||
<span>
|
||
{{ formatKiroUsage(getKiroQuotaDisplay(key)?.current_usage) }} /
|
||
{{ formatKiroUsage(getKiroQuotaDisplay(key)?.usage_limit) }}
|
||
</span>
|
||
<span v-if="getKiroQuotaDisplay(key)?.next_reset_at">
|
||
{{ formatKiroResetTime(getKiroQuotaDisplay(key)?.next_reset_at) }}{{ legacyT('重置') }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</ProviderQuotaProgressRow>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<!-- Windsurf 上游额度信息 -->
|
||
<div
|
||
v-if="provider.provider_type === 'windsurf' && (hasWindsurfQuotaDisplayData(key) || isWindsurfUnavailableKey(key) || isWindsurfExhaustedKey(key))"
|
||
class="mt-2 p-2 rounded-md"
|
||
:class="isWindsurfUnavailableKey(key) ? 'bg-destructive/10 border border-destructive/30' : (isWindsurfExhaustedKey(key) ? 'bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/50' : 'bg-muted/30')"
|
||
>
|
||
<div
|
||
v-if="isWindsurfUnavailableKey(key)"
|
||
class="flex items-center gap-2 text-destructive"
|
||
>
|
||
<ShieldX class="w-4 h-4 shrink-0" />
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-[11px] font-medium">
|
||
{{ legacyT('账号不可用') }}
|
||
</div>
|
||
<div
|
||
v-if="getWindsurfQuotaDisplay(key)?.last_error"
|
||
class="text-[10px] text-destructive/80 truncate"
|
||
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
|
||
>
|
||
{{ getWindsurfQuotaDisplay(key)?.last_error }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template v-else>
|
||
<div
|
||
v-if="isWindsurfExhaustedKey(key)"
|
||
class="mb-2 flex items-center gap-2 text-amber-700 dark:text-amber-300"
|
||
>
|
||
<ShieldX class="w-4 h-4 shrink-0" />
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-[11px] font-medium">
|
||
{{ getWindsurfQuotaStatusLabel(key) }}
|
||
</div>
|
||
<div
|
||
v-if="getWindsurfQuotaDisplay(key)?.last_error"
|
||
class="text-[10px] text-amber-700/80 dark:text-amber-300/80 truncate"
|
||
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
|
||
>
|
||
{{ getWindsurfQuotaDisplay(key)?.last_error }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<ProviderQuotaSectionHeader
|
||
:title="legacyT('账号配额')"
|
||
:loading="refreshingQuota"
|
||
:updated-text="getWindsurfQuotaDisplay(key)?.updated_at ? formatKiroUpdatedAt(getWindsurfQuotaDisplay(key)?.updated_at || 0) : null"
|
||
/>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<ProviderQuotaProgressRow
|
||
v-if="getWindsurfQuotaDisplay(key)?.daily_remaining_percent !== undefined"
|
||
:label="legacyT('日额度')"
|
||
:used-percent="getWindsurfQuotaDisplay(key)?.daily_used_percent || 0"
|
||
:remaining-percent="getWindsurfQuotaDisplay(key)?.daily_remaining_percent || 0"
|
||
:meter-class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)"
|
||
:bar-class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)"
|
||
:reset-text="getWindsurfQuotaDisplay(key)?.daily_reset_at
|
||
? `${formatKiroResetTime(getWindsurfQuotaDisplay(key)?.daily_reset_at || 0)}${legacyT('重置')}`
|
||
: null"
|
||
/>
|
||
<ProviderQuotaProgressRow
|
||
v-if="getWindsurfQuotaDisplay(key)?.weekly_remaining_percent !== undefined"
|
||
:label="legacyT('周额度')"
|
||
:used-percent="getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0"
|
||
:remaining-percent="getWindsurfQuotaDisplay(key)?.weekly_remaining_percent || 0"
|
||
:meter-class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)"
|
||
:bar-class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)"
|
||
:reset-text="getWindsurfQuotaDisplay(key)?.weekly_reset_at
|
||
? `${formatKiroResetTime(getWindsurfQuotaDisplay(key)?.weekly_reset_at || 0)}${legacyT('重置')}`
|
||
: null"
|
||
/>
|
||
</div>
|
||
<div
|
||
v-if="hasWindsurfPromptQuota(key) || hasWindsurfFlexQuota(key)"
|
||
class="mt-2 flex items-center gap-3 text-[9px] text-muted-foreground/70"
|
||
>
|
||
<span v-if="hasWindsurfPromptQuota(key)">
|
||
Prompt {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_used || 0) }} /
|
||
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_limit || 0) }}
|
||
</span>
|
||
<span v-if="hasWindsurfFlexQuota(key)">
|
||
Flex {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_used || 0) }} /
|
||
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_limit || 0) }}
|
||
</span>
|
||
</div>
|
||
<div
|
||
v-if="hasWindsurfModelCount(key) || hasWindsurfModelPreview(key)"
|
||
class="mt-2 flex items-center justify-between gap-2 text-[9px] text-muted-foreground/70"
|
||
>
|
||
<span>
|
||
{{ legacyT('模型') }} {{ getWindsurfQuotaDisplay(key)?.allowed_models_count ?? getWindsurfQuotaDisplay(key)?.models?.length }} {{ legacyT('个') }}
|
||
</span>
|
||
<span
|
||
v-if="getWindsurfModelPreview(key)"
|
||
class="truncate"
|
||
:title="getWindsurfModelPreview(key) || ''"
|
||
>
|
||
{{ getWindsurfModelPreview(key) }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<!-- ChatGPT Web 上游额度信息(生图配额) -->
|
||
<div
|
||
v-if="provider.provider_type === 'chatgpt_web' && hasChatGPTWebQuotaDisplayData(key)"
|
||
class="mt-2 p-2 rounded-md bg-muted/30"
|
||
>
|
||
<ProviderQuotaSectionHeader
|
||
:title="legacyT('账号配额')"
|
||
:loading="refreshingQuota"
|
||
:updated-text="getChatGPTWebQuotaDisplay(key)?.updated_at ? formatKiroUpdatedAt(getChatGPTWebQuotaDisplay(key)?.updated_at || 0) : null"
|
||
/>
|
||
<div>
|
||
<ProviderQuotaProgressRow
|
||
:label="legacyT('剩余额度')"
|
||
:used-percent="getChatGPTWebQuotaUsedPercent(key)"
|
||
:remaining-percent="getChatGPTWebQuotaRemainingPercent(key)"
|
||
:meter-class="getQuotaRemainingClass(getChatGPTWebQuotaUsedPercent(key))"
|
||
:bar-class="getQuotaRemainingBarColor(getChatGPTWebQuotaUsedPercent(key))"
|
||
>
|
||
<template #footer>
|
||
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
||
<span>
|
||
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_remaining) }} /
|
||
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_total) }}
|
||
</span>
|
||
<span v-if="getChatGPTWebQuotaDisplay(key)?.image_quota_reset_at">
|
||
{{ formatKiroResetTime(getChatGPTWebQuotaDisplay(key)?.image_quota_reset_at) }}{{ legacyT('重置') }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</ProviderQuotaProgressRow>
|
||
</div>
|
||
</div>
|
||
<!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 -->
|
||
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
|
||
<!-- 优先级放最前面,支持点击编辑 -->
|
||
<span
|
||
v-if="editingPriorityKey !== key.id"
|
||
:title="legacyT('点击编辑优先级')"
|
||
class="font-medium text-foreground/80 cursor-pointer hover:text-primary hover:underline"
|
||
@click="startEditPriority(key)"
|
||
>P{{ key.internal_priority }}</span>
|
||
<input
|
||
v-else
|
||
ref="priorityInputRef"
|
||
v-model="editingPriorityValue"
|
||
type="text"
|
||
inputmode="numeric"
|
||
pattern="[0-9]*"
|
||
class="w-8 h-5 px-1 text-[11px] text-center border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary font-medium text-foreground/80"
|
||
@keydown="(e) => handlePriorityKeydown(e, key)"
|
||
@blur="handlePriorityBlur(key)"
|
||
>
|
||
<!-- 自动获取模型状态 -->
|
||
<template v-if="key.auto_fetch_models">
|
||
<span class="text-muted-foreground/40">|</span>
|
||
<span
|
||
class="cursor-help"
|
||
:class="key.last_models_fetch_error ? 'text-amber-600 dark:text-amber-400' : ''"
|
||
:title="getAutoFetchStatusTitle(key)"
|
||
>
|
||
{{ legacyT(key.last_models_fetch_error ? '同步失败' : '自动同步') }}
|
||
</span>
|
||
</template>
|
||
<!-- RPM 限制信息(第二位) -->
|
||
<template v-if="key.rpm_limit || key.is_adaptive">
|
||
<span class="text-muted-foreground/40">|</span>
|
||
<span v-if="key.is_adaptive">
|
||
{{ key.learned_rpm_limit != null ? `${key.learned_rpm_limit}` : legacyT('探测中') }} RPM
|
||
<span class="text-muted-foreground/60">({{ legacyT('自适应') }})</span>
|
||
</span>
|
||
<span v-else>{{ key.rpm_limit }} RPM</span>
|
||
</template>
|
||
<span class="text-muted-foreground/40">|</span>
|
||
<!-- API 格式:展开显示每个格式、倍率、熔断状态 -->
|
||
<template
|
||
v-for="(format, idx) in getKeyApiFormats(key, endpoint)"
|
||
:key="format"
|
||
>
|
||
<span
|
||
v-if="idx > 0"
|
||
class="text-muted-foreground/40"
|
||
>/</span>
|
||
<span :class="{ 'text-destructive': isFormatCircuitOpen(key, format) }">
|
||
{{ formatApiFormatShort(format) }}
|
||
</span>
|
||
<span
|
||
v-if="editingMultiplierKey !== key.id || editingMultiplierFormat !== format"
|
||
:title="legacyT('点击编辑倍率')"
|
||
class="cursor-pointer hover:text-primary hover:underline"
|
||
:class="{ 'text-destructive': isFormatCircuitOpen(key, format) }"
|
||
@click="startEditMultiplier(key, format)"
|
||
>{{ getKeyRateMultiplier(key, format) }}x</span>
|
||
<input
|
||
v-else
|
||
ref="multiplierInputRef"
|
||
v-model="editingMultiplierValue"
|
||
type="text"
|
||
inputmode="decimal"
|
||
pattern="[0-9]*\.?[0-9]*"
|
||
class="w-10 h-5 px-1 text-[11px] text-center border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary font-medium text-foreground/80"
|
||
@keydown="(e) => handleMultiplierKeydown(e, key, format)"
|
||
@blur="handleMultiplierBlur(key, format)"
|
||
>
|
||
<span
|
||
v-if="getFormatProbeCountdown(key, format)"
|
||
:class="{ 'text-destructive': isFormatCircuitOpen(key, format) }"
|
||
>{{ getFormatProbeCountdown(key, format) }}</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<!-- 分页控制 -->
|
||
<div
|
||
v-if="shouldPaginateKeys"
|
||
class="px-4 py-2 flex items-center justify-between text-xs text-muted-foreground mt-auto"
|
||
>
|
||
<span>{{ legacyT('共') }} {{ allKeys.length }} {{ legacyT('个') }}{{ legacyT(isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号') }}</span>
|
||
<div class="flex items-center gap-1.5">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-6 px-2 text-xs"
|
||
:disabled="loadingProviderKeys || currentKeyPage <= 1"
|
||
@click="goToKeyPage(currentKeyPage - 1)"
|
||
>
|
||
‹
|
||
</Button>
|
||
<span class="tabular-nums">{{ currentKeyPage }} / {{ totalKeyPages }}</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-6 px-2 text-xs"
|
||
:disabled="loadingProviderKeys || currentKeyPage >= totalKeyPages"
|
||
@click="goToKeyPage(currentKeyPage + 1)"
|
||
>
|
||
›
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div
|
||
v-else
|
||
class="p-8 text-center text-muted-foreground"
|
||
>
|
||
<Key class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||
<p class="text-sm">
|
||
{{ legacyT(isKeyManagedProviderType(provider.provider_type) ? '暂无密钥配置' : '暂无账号配置') }}
|
||
</p>
|
||
<p class="text-xs mt-1">
|
||
{{ endpoints.length > 0
|
||
? legacyT(isKeyManagedProviderType(provider.provider_type) ? '点击上方"添加密钥"按钮创建第一个密钥' : '点击上方"添加账号"按钮添加第一个账号')
|
||
: legacyT('请先添加端点,然后再添加密钥') }}
|
||
</p>
|
||
</div>
|
||
</Card>
|
||
|
||
<!-- 模型查看 -->
|
||
<ModelsTab
|
||
v-if="provider"
|
||
:key="`models-${provider.id}`"
|
||
:provider="provider"
|
||
:models="providerModels"
|
||
:endpoints="endpoints"
|
||
:provider-keys="providerKeys"
|
||
:loading="loadingProviderModels"
|
||
@edit-model="handleEditModel"
|
||
@batch-assign="handleBatchAssign"
|
||
@refresh="loadEndpoints"
|
||
/>
|
||
|
||
<!-- 模型映射 -->
|
||
<ModelMappingTab
|
||
v-if="provider"
|
||
ref="modelMappingTabRef"
|
||
:key="`mapping-${provider.id}`"
|
||
:provider="provider"
|
||
:endpoints="endpoints"
|
||
:provider-keys="providerKeys"
|
||
:models="providerModels"
|
||
:mapping-preview="providerMappingPreview"
|
||
:loading="loadingProviderMappingPreview"
|
||
@refresh="handleModelMappingChanged"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</Card>
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
|
||
<!-- 端点表单对话框(管理/编辑) -->
|
||
<EndpointFormDialog
|
||
v-if="provider && open && endpointDialogOpen"
|
||
v-model="endpointDialogOpen"
|
||
:provider="provider"
|
||
:endpoints="endpoints"
|
||
:system-format-conversion-enabled="systemFormatConversionEnabled"
|
||
:provider-format-conversion-enabled="provider.enable_format_conversion"
|
||
@endpoint-created="handleEndpointChanged"
|
||
@endpoint-updated="handleEndpointChanged"
|
||
/>
|
||
|
||
<!-- 密钥编辑对话框 -->
|
||
<KeyFormDialog
|
||
v-if="open && keyFormDialogOpen"
|
||
:open="keyFormDialogOpen"
|
||
:endpoint="currentEndpoint"
|
||
:editing-key="editingKey"
|
||
:provider-id="provider ? provider.id : null"
|
||
:provider-type="provider?.provider_type || null"
|
||
:available-api-formats="availableKeyApiFormats"
|
||
@close="keyFormDialogOpen = false"
|
||
@saved="handleKeyChanged"
|
||
/>
|
||
|
||
<ProviderKeyBatchImportDialog
|
||
v-if="open && keyBatchImportDialogOpen && provider?.provider_type === 'custom'"
|
||
:open="keyBatchImportDialogOpen"
|
||
:provider-id="provider.id"
|
||
:provider-name="provider.name"
|
||
:available-api-formats="availableKeyApiFormats"
|
||
@close="keyBatchImportDialogOpen = false"
|
||
@saved="handleKeyChanged"
|
||
/>
|
||
|
||
<!-- OAuth 账号对话框 -->
|
||
<OAuthAccountDialog
|
||
v-if="open && oauthAccountDialogOpen && provider"
|
||
:open="oauthAccountDialogOpen"
|
||
:provider-id="provider.id"
|
||
:provider-type="provider.provider_type"
|
||
@close="oauthAccountDialogOpen = false"
|
||
@saved="handleKeyChanged"
|
||
/>
|
||
|
||
<!-- OAuth 密钥编辑对话框 -->
|
||
<OAuthKeyEditDialog
|
||
v-if="open && oauthKeyEditDialogOpen"
|
||
:open="oauthKeyEditDialogOpen"
|
||
:editing-key="editingKey"
|
||
@close="oauthKeyEditDialogOpen = false"
|
||
@saved="handleKeyChanged"
|
||
/>
|
||
|
||
<!-- 模型权限对话框 -->
|
||
<KeyAllowedModelsEditDialog
|
||
v-if="open && keyPermissionsDialogOpen"
|
||
:open="keyPermissionsDialogOpen"
|
||
:api-key="editingKey"
|
||
:provider-id="providerId || ''"
|
||
@close="keyPermissionsDialogOpen = false"
|
||
@saved="handleKeyChanged"
|
||
/>
|
||
|
||
<!-- 删除密钥确认对话框 -->
|
||
<AlertDialog
|
||
v-if="open && deleteKeyConfirmOpen"
|
||
:model-value="deleteKeyConfirmOpen"
|
||
:title="legacyT('删除密钥')"
|
||
:description="formatDeleteKeyConfirmDescription()"
|
||
:confirm-text="legacyT('删除')"
|
||
:cancel-text="legacyT('取消')"
|
||
type="danger"
|
||
@update:model-value="deleteKeyConfirmOpen = $event"
|
||
@confirm="confirmDeleteKey"
|
||
@cancel="deleteKeyConfirmOpen = false"
|
||
/>
|
||
|
||
<!-- 添加/编辑模型对话框 -->
|
||
<ProviderModelFormDialog
|
||
v-if="open && modelFormDialogOpen && provider"
|
||
:open="modelFormDialogOpen"
|
||
:provider-id="provider.id"
|
||
:provider-name="provider.name"
|
||
:editing-model="editingModel"
|
||
@update:open="modelFormDialogOpen = $event"
|
||
@saved="handleModelSaved"
|
||
/>
|
||
|
||
<!-- 批量关联模型对话框 -->
|
||
<BatchAssignModelsDialog
|
||
v-if="open && batchAssignDialogOpen && provider"
|
||
:open="batchAssignDialogOpen"
|
||
:provider-id="provider.id"
|
||
:provider-name="provider.name"
|
||
@update:open="handleBatchAssignDialogOpenUpdate"
|
||
@changed="handleBatchAssignChanged"
|
||
/>
|
||
|
||
<!-- Antigravity 配额详情弹窗 -->
|
||
<AntigravityQuotaDialog
|
||
v-if="antigravityQuotaDialogKey"
|
||
:open="antigravityQuotaDialogOpen"
|
||
:metadata="antigravityQuotaDialogKey.upstream_metadata"
|
||
:quota-snapshot="antigravityQuotaDialogKey.status_snapshot?.quota ?? null"
|
||
:key-name="antigravityQuotaDialogKey.name || legacyT('未命名密钥')"
|
||
:provider-id="providerId"
|
||
:key-id="antigravityQuotaDialogKey.id"
|
||
@update:open="antigravityQuotaDialogOpen = $event"
|
||
/>
|
||
|
||
<!-- 故障转移规则弹窗 -->
|
||
<FailoverRulesDialog
|
||
v-if="open && failoverRulesDialogOpen"
|
||
:open="failoverRulesDialogOpen"
|
||
:provider="provider ?? null"
|
||
@update:open="failoverRulesDialogOpen = $event"
|
||
@saved="loadProvider()"
|
||
/>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, computed, nextTick } from 'vue'
|
||
import {
|
||
Plus,
|
||
Key,
|
||
ListPlus,
|
||
Loader2,
|
||
GripVertical,
|
||
ShieldX,
|
||
} from 'lucide-vue-next'
|
||
import { parseApiError } from '@/utils/errorParser'
|
||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||
import { useI18n } from '@/i18n'
|
||
import Button from '@/components/ui/button.vue'
|
||
import Card from '@/components/ui/card.vue'
|
||
import { useToast } from '@/composables/useToast'
|
||
import { useConfirm } from '@/composables/useConfirm'
|
||
import { useClipboard } from '@/composables/useClipboard'
|
||
import { useCountdownTimer, formatCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
||
import {
|
||
getProvider,
|
||
getProviderEndpoints,
|
||
updateProvider,
|
||
getProviderModels,
|
||
getProviderMappingPreview,
|
||
type ProviderMappingPreviewResponse,
|
||
type ProviderWithEndpointsSummary,
|
||
} from '@/api/endpoints'
|
||
import { adminApi } from '@/api/admin'
|
||
import {
|
||
KeyFormDialog,
|
||
KeyAllowedModelsEditDialog,
|
||
ModelsTab,
|
||
BatchAssignModelsDialog,
|
||
OAuthAccountDialog,
|
||
OAuthKeyEditDialog
|
||
} from '@/features/providers/components'
|
||
import ModelMappingTab from '@/features/providers/components/provider-tabs/ModelMappingTab.vue'
|
||
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 FailoverRulesDialog from '@/features/providers/components/FailoverRulesDialog.vue'
|
||
import ProviderDetailHeader from '@/features/providers/components/ProviderDetailHeader.vue'
|
||
import ProviderKeyBatchImportDialog from '@/features/providers/components/ProviderKeyBatchImportDialog.vue'
|
||
import ProviderKeyActionCluster from '@/features/providers/components/ProviderKeyActionCluster.vue'
|
||
import ProviderKeyIdentityBlock from '@/features/providers/components/ProviderKeyIdentityBlock.vue'
|
||
import ProviderMonthlyQuotaCard from '@/features/providers/components/ProviderMonthlyQuotaCard.vue'
|
||
import ProviderQuotaProgressRow from '@/features/providers/components/ProviderQuotaProgressRow.vue'
|
||
import ProviderQuotaSectionHeader from '@/features/providers/components/ProviderQuotaSectionHeader.vue'
|
||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||
import {
|
||
compareAntigravityQuotaItems,
|
||
dedupeAntigravityQuotaItemsByLabel,
|
||
resolveAntigravityQuotaLabel,
|
||
} from '@/features/providers/utils/antigravityQuota'
|
||
import {
|
||
deleteEndpointKey,
|
||
recoverKeyHealth,
|
||
getProviderKeysPage,
|
||
updateProviderKey,
|
||
revealEndpointKey,
|
||
exportKey,
|
||
refreshProviderOAuth,
|
||
refreshProviderQuota,
|
||
consumeCodexResetCredit,
|
||
clearOAuthInvalid,
|
||
type ProviderEndpoint,
|
||
type EndpointAPIKey,
|
||
type Model,
|
||
API_FORMAT_ORDER,
|
||
sortApiFormats,
|
||
} from '@/api/endpoints'
|
||
import type {
|
||
UpstreamMetadata,
|
||
AntigravityModelQuota,
|
||
CodexUpstreamMetadata,
|
||
ChatGPTWebUpstreamMetadata,
|
||
GrokUpstreamMetadata,
|
||
KiroUpstreamMetadata,
|
||
WindsurfUpstreamMetadata,
|
||
QuotaResetCreditsSnapshot,
|
||
QuotaStatusSnapshot,
|
||
QuotaWindowSnapshot,
|
||
} from '@/api/endpoints/types'
|
||
import { formatApiFormatShort } from '@/api/endpoints/types/api-format'
|
||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
||
import {
|
||
getCodexPrimaryQuotaWindow,
|
||
getCodexQuotaWindowLimitLabel,
|
||
} from '@/utils/codexQuotaWindow'
|
||
import { formatCompactNumber } from '@/utils/format'
|
||
import {
|
||
canEditOAuthCredential,
|
||
canExportOAuthCredential,
|
||
canRefreshOAuthCredential,
|
||
isOAuthManagedCredential,
|
||
getProviderMaskedSecretLabel,
|
||
shouldShowOAuthRefreshControl,
|
||
} from '@/utils/providerKeyAuth'
|
||
import {
|
||
getAccountStatusDisplay,
|
||
getAccountStatusTitle,
|
||
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
|
||
getOAuthStatusDisplay,
|
||
getOAuthStatusDisplayWithFallback,
|
||
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||
} from '@/utils/providerKeyStatus'
|
||
import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
|
||
import {
|
||
createCodexResetCreditIdempotencyKey,
|
||
formatCodexResetCreditCount as formatCodexResetCreditCountLabel,
|
||
formatCodexResetCreditExpiresAt,
|
||
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
|
||
getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot,
|
||
mergeCodexQuotaDisplays,
|
||
} from './codex-reset-credit-display'
|
||
|
||
// 扩展端点类型,包含密钥列表
|
||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||
keys?: EndpointAPIKey[]
|
||
rpm_limit?: number
|
||
}
|
||
|
||
interface Props {
|
||
providerId: string | null
|
||
open: boolean
|
||
initialProvider?: ProviderWithEndpointsSummary | null
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
const emit = defineEmits<{
|
||
(e: 'update:open', value: boolean): void
|
||
(e: 'edit', provider: ProviderWithEndpointsSummary): void
|
||
(e: 'toggleStatus', provider: ProviderWithEndpointsSummary): void
|
||
(e: 'refresh'): void
|
||
}>()
|
||
|
||
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
||
const { confirm } = useConfirm()
|
||
const { copyToClipboard } = useClipboard()
|
||
const { tick: countdownTick, start: startCountdownTimer, stop: stopCountdownTimer } = useCountdownTimer()
|
||
const { legacyT, locale } = useI18n()
|
||
|
||
function localizedApiError(err: unknown, fallback: string): string {
|
||
return legacyT(parseApiError(err, fallback))
|
||
}
|
||
|
||
const loading = ref(false)
|
||
const provider = ref<ProviderWithEndpointsSummary | null>(null)
|
||
const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
||
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
|
||
const providerModels = ref<Model[]>([]) // Provider 级别的 models
|
||
const providerMappingPreview = ref<ProviderMappingPreviewResponse | null>(null) // 映射预览
|
||
const loadingProviderEndpoints = ref(false)
|
||
const loadingProviderKeys = ref(false)
|
||
const loadingProviderModels = ref(false)
|
||
const loadingProviderMappingPreview = ref(false)
|
||
let providerLoadRequestId = 0
|
||
let endpointsLoadRequestId = 0
|
||
let keysLoadRequestId = 0
|
||
let mappingPreviewLoadRequestId = 0
|
||
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
|
||
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4
|
||
|
||
function applyProviderSnapshot(updated: ProviderWithEndpointsSummary): void {
|
||
if (provider.value?.id === updated.id) {
|
||
Object.assign(provider.value, updated)
|
||
return
|
||
}
|
||
provider.value = updated
|
||
}
|
||
|
||
function getProviderKeysPageSize(providerType?: string | null): number {
|
||
return (providerType || '').trim().toLowerCase() === 'custom'
|
||
? CUSTOM_PROVIDER_KEYS_PAGE_SIZE
|
||
: DEFAULT_PROVIDER_KEYS_PAGE_SIZE
|
||
}
|
||
|
||
// 系统级格式转换配置
|
||
const systemFormatConversionEnabled = ref(false)
|
||
|
||
// 端点相关状态
|
||
const endpointDialogOpen = ref(false)
|
||
|
||
// 密钥相关状态
|
||
const keyFormDialogOpen = ref(false)
|
||
const keyBatchImportDialogOpen = ref(false)
|
||
const keyPermissionsDialogOpen = ref(false)
|
||
const oauthAccountDialogOpen = ref(false)
|
||
const oauthKeyEditDialogOpen = ref(false)
|
||
const currentEndpoint = ref<ProviderEndpoint | null>(null)
|
||
const editingKey = ref<EndpointAPIKey | null>(null)
|
||
const deleteKeyConfirmOpen = ref(false)
|
||
const keyToDelete = ref<EndpointAPIKey | null>(null)
|
||
const togglingKeyId = ref<string | null>(null)
|
||
|
||
// 密钥显示状态:key_id -> 完整密钥
|
||
const revealedKeys = ref<Map<string, string>>(new Map())
|
||
|
||
// 模型相关状态
|
||
const modelFormDialogOpen = ref(false)
|
||
const editingModel = ref<Model | null>(null)
|
||
const batchAssignDialogOpen = ref(false)
|
||
const modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null)
|
||
|
||
// 密钥列表拖拽排序状态
|
||
const keyDragState = ref({
|
||
isDragging: false,
|
||
draggedIndex: null as number | null,
|
||
targetIndex: null as number | null
|
||
})
|
||
|
||
// 点击编辑优先级相关状态
|
||
const editingPriorityKey = ref<string | null>(null)
|
||
const editingPriorityValue = ref<number>(0)
|
||
const priorityInputRef = ref<HTMLInputElement[] | null>(null)
|
||
const prioritySaving = ref(false)
|
||
|
||
// OAuth 刷新状态
|
||
const refreshingOAuthKeyId = ref<string | null>(null)
|
||
|
||
// OAuth 失效清除状态
|
||
const clearingOAuthInvalidKeyId = ref<string | null>(null)
|
||
|
||
// Codex reset credit 消费状态
|
||
const consumingCodexResetCreditKeyId = ref<string | null>(null)
|
||
|
||
// 限额刷新状态(Codex / Antigravity)
|
||
const refreshingQuota = ref(false)
|
||
|
||
// Antigravity 配额详情弹窗状态
|
||
const antigravityQuotaDialogOpen = ref(false)
|
||
const antigravityQuotaDialogKey = ref<EndpointAPIKey | null>(null)
|
||
|
||
// 故障转移规则
|
||
const failoverRulesDialogOpen = ref(false)
|
||
const FAILOVER_RULE_ARRAY_KEYS = [
|
||
'success_failover_patterns',
|
||
'error_stop_patterns',
|
||
'stop_status_codes',
|
||
'stop_on_status_codes',
|
||
'early_stop_status_codes',
|
||
'non_retryable_status_codes',
|
||
'continue_on_status_codes',
|
||
'retryable_status_codes',
|
||
'retry_on_status_codes',
|
||
'continue_status_codes',
|
||
] as const
|
||
const hasFailoverRules = computed(() => {
|
||
const rules = provider.value?.failover_rules
|
||
if (!rules) return false
|
||
return FAILOVER_RULE_ARRAY_KEYS.some(key => (rules[key]?.length || 0) > 0)
|
||
|| typeof rules.max_retries === 'number'
|
||
|| rules.stop_on_transport_errors === true
|
||
})
|
||
|
||
// Provider 级别代理配置状态
|
||
const proxyNodesStore = useProxyNodesStore()
|
||
const providerProxyPopoverOpen = ref(false)
|
||
const savingProviderProxy = ref(false)
|
||
|
||
// Key 级别代理配置状态
|
||
const savingProxyKeyId = ref<string | null>(null)
|
||
const proxyPopoverOpenKeyId = ref<string | null>(null)
|
||
|
||
// 点击编辑倍率相关状态
|
||
const editingMultiplierKey = ref<string | null>(null)
|
||
const editingMultiplierFormat = ref<string | null>(null)
|
||
const editingMultiplierValue = ref<number>(1.0)
|
||
const multiplierInputRef = ref<HTMLInputElement[] | null>(null)
|
||
const multiplierSaving = ref(false)
|
||
|
||
// 任意模态窗口打开时,阻止抽屉被误关闭
|
||
const hasBlockingDialogOpen = computed(() =>
|
||
endpointDialogOpen.value ||
|
||
keyFormDialogOpen.value ||
|
||
keyBatchImportDialogOpen.value ||
|
||
keyPermissionsDialogOpen.value ||
|
||
oauthAccountDialogOpen.value ||
|
||
oauthKeyEditDialogOpen.value ||
|
||
deleteKeyConfirmOpen.value ||
|
||
modelFormDialogOpen.value ||
|
||
batchAssignDialogOpen.value ||
|
||
antigravityQuotaDialogOpen.value ||
|
||
modelMappingTabRef.value?.dialogOpen
|
||
)
|
||
|
||
// 当前后端分页页内的密钥列表。key 通过 api_formats 字段确定支持的格式,endpoint 可能为 undefined。
|
||
const allKeys = computed(() => {
|
||
return providerKeys.value.map(key => ({ key, endpoint: undefined as ProviderEndpointWithKeys | undefined }))
|
||
})
|
||
|
||
const availableKeyApiFormats = computed(() => {
|
||
const formatSet = new Set<string>()
|
||
|
||
for (const format of provider.value?.api_formats || []) {
|
||
if (format) {
|
||
formatSet.add(format)
|
||
}
|
||
}
|
||
|
||
for (const endpoint of endpoints.value) {
|
||
if (endpoint.api_format) {
|
||
formatSet.add(endpoint.api_format)
|
||
}
|
||
}
|
||
|
||
return sortApiFormats([...formatSet])
|
||
})
|
||
|
||
function syncCurrentSelections(
|
||
nextEndpoints: ProviderEndpointWithKeys[] = endpoints.value,
|
||
nextProviderKeys: EndpointAPIKey[] = providerKeys.value
|
||
) {
|
||
if (currentEndpoint.value) {
|
||
currentEndpoint.value = nextEndpoints.find(endpoint => endpoint.id === currentEndpoint.value?.id) ?? null
|
||
}
|
||
|
||
if (!editingKey.value) {
|
||
return
|
||
}
|
||
|
||
const latestKeys: EndpointAPIKey[] = []
|
||
const seenKeyIds = new Set<string>()
|
||
|
||
for (const key of nextProviderKeys) {
|
||
if (!seenKeyIds.has(key.id)) {
|
||
seenKeyIds.add(key.id)
|
||
latestKeys.push(key)
|
||
}
|
||
}
|
||
|
||
for (const endpoint of nextEndpoints) {
|
||
for (const key of endpoint.keys || []) {
|
||
if (!seenKeyIds.has(key.id)) {
|
||
seenKeyIds.add(key.id)
|
||
latestKeys.push(key)
|
||
}
|
||
}
|
||
}
|
||
|
||
const latestEditingKey = latestKeys.find(key => key.id === editingKey.value?.id) || null
|
||
editingKey.value = latestEditingKey
|
||
|
||
if (!latestEditingKey) {
|
||
keyFormDialogOpen.value = false
|
||
keyPermissionsDialogOpen.value = false
|
||
oauthKeyEditDialogOpen.value = false
|
||
}
|
||
}
|
||
|
||
// ===== 账号列表后端分页 =====
|
||
const providerKeysTotal = ref(0)
|
||
const currentKeyPage = ref(1)
|
||
const keyPageSize = ref(DEFAULT_PROVIDER_KEYS_PAGE_SIZE)
|
||
const totalKeyPages = computed(() => Math.max(1, Math.ceil(providerKeysTotal.value / keyPageSize.value)))
|
||
const shouldPaginateKeys = computed(() => totalKeyPages.value > 1)
|
||
const paginatedKeys = computed(() => allKeys.value)
|
||
|
||
function getGlobalKeyIndex(localIdx: number): number {
|
||
return localIdx
|
||
}
|
||
|
||
async function goToKeyPage(page: number) {
|
||
const nextPage = Math.min(Math.max(page, 1), totalKeyPages.value)
|
||
if (nextPage === currentKeyPage.value && providerKeys.value.length > 0) return
|
||
await loadProviderKeysPage(nextPage)
|
||
}
|
||
|
||
// 合并监听 providerId 和 open,避免同一 tick 内两个 watcher 都触发导致重复请求
|
||
watch(
|
||
[() => props.providerId, () => props.open],
|
||
async ([newId, newOpen], [_oldId, oldOpen]) => {
|
||
if (newOpen && newId) {
|
||
if (!oldOpen || provider.value?.id !== newId) {
|
||
currentKeyPage.value = 1
|
||
providerKeysTotal.value = 0
|
||
}
|
||
const hasInitialProvider = props.initialProvider?.id === newId
|
||
if (hasInitialProvider) {
|
||
provider.value = props.initialProvider
|
||
keyPageSize.value = getProviderKeysPageSize(provider.value?.provider_type)
|
||
loading.value = false
|
||
}
|
||
void loadSystemFormatConversionConfig()
|
||
if (!hasInitialProvider) {
|
||
await loadProvider()
|
||
}
|
||
const endpointsPromise = loadEndpoints()
|
||
// 仅在抽屉刚打开时启动倒计时
|
||
if (newOpen && !oldOpen) {
|
||
startCountdownTimer()
|
||
}
|
||
// 优先完成端点、密钥和模型的首屏数据,再请求计算量较大的映射预览。
|
||
// 同时校验抽屉状态,避免关闭或切换 Provider 后启动无用请求。
|
||
void endpointsPromise.then(() => {
|
||
if (!props.open || props.providerId !== newId) return
|
||
void loadMappingPreview()
|
||
void autoRefreshQuotaInBackground()
|
||
})
|
||
} else if (!newOpen && oldOpen) {
|
||
// 使在途请求失效,避免关闭后旧响应回写
|
||
providerLoadRequestId += 1
|
||
endpointsLoadRequestId += 1
|
||
keysLoadRequestId += 1
|
||
mappingPreviewLoadRequestId += 1
|
||
|
||
// 停止倒计时定时器
|
||
stopCountdownTimer()
|
||
// 重置所有状态
|
||
loading.value = false
|
||
provider.value = null
|
||
endpoints.value = []
|
||
providerKeys.value = [] // 清空 Provider 级别的 keys
|
||
providerKeysTotal.value = 0
|
||
currentKeyPage.value = 1
|
||
keyPageSize.value = DEFAULT_PROVIDER_KEYS_PAGE_SIZE
|
||
providerModels.value = []
|
||
providerMappingPreview.value = null
|
||
loadingProviderEndpoints.value = false
|
||
loadingProviderKeys.value = false
|
||
loadingProviderModels.value = false
|
||
loadingProviderMappingPreview.value = false
|
||
|
||
// 重置所有对话框状态
|
||
endpointDialogOpen.value = false
|
||
keyFormDialogOpen.value = false
|
||
keyBatchImportDialogOpen.value = false
|
||
keyPermissionsDialogOpen.value = false
|
||
oauthAccountDialogOpen.value = false
|
||
oauthKeyEditDialogOpen.value = false
|
||
deleteKeyConfirmOpen.value = false
|
||
batchAssignDialogOpen.value = false
|
||
antigravityQuotaDialogOpen.value = false
|
||
antigravityQuotaDialogKey.value = null
|
||
|
||
// 重置临时数据
|
||
currentEndpoint.value = null
|
||
editingKey.value = null
|
||
keyToDelete.value = null
|
||
|
||
// 清除已显示的密钥(安全考虑)
|
||
revealedKeys.value.clear()
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 处理背景点击
|
||
function handleBackdropClick() {
|
||
if (!hasBlockingDialogOpen.value) {
|
||
handleClose()
|
||
}
|
||
}
|
||
|
||
// 关闭抽屉
|
||
function handleClose() {
|
||
if (!hasBlockingDialogOpen.value) {
|
||
emit('update:open', false)
|
||
}
|
||
}
|
||
|
||
// 切换格式转换开关
|
||
async function toggleFormatConversion() {
|
||
if (!provider.value) return
|
||
const newValue = !provider.value.enable_format_conversion
|
||
try {
|
||
const updated = await updateProvider(provider.value.id, { enable_format_conversion: newValue })
|
||
applyProviderSnapshot(updated)
|
||
showSuccess(legacyT(newValue ? '已启用格式转换' : '已禁用格式转换'))
|
||
emit('refresh')
|
||
} catch {
|
||
showError(legacyT('切换格式转换失败'))
|
||
}
|
||
}
|
||
|
||
function getProviderProxyNodeName(): string {
|
||
const nodeId = provider.value?.proxy?.node_id
|
||
if (!nodeId) return legacyT('未知节点')
|
||
const node = proxyNodesStore.nodes.find(n => n.id === nodeId)
|
||
return node ? node.name : `${nodeId.slice(0, 8)}...`
|
||
}
|
||
|
||
async function setProviderProxy(nodeId: string) {
|
||
if (!provider.value) return
|
||
savingProviderProxy.value = true
|
||
try {
|
||
const updated = await updateProvider(provider.value.id, {
|
||
proxy: { node_id: nodeId, enabled: true },
|
||
})
|
||
applyProviderSnapshot(updated)
|
||
providerProxyPopoverOpen.value = false
|
||
showSuccess(legacyT('代理节点已设置'))
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '设置代理失败'))
|
||
} finally {
|
||
savingProviderProxy.value = false
|
||
}
|
||
}
|
||
|
||
async function clearProviderProxy() {
|
||
if (!provider.value) return
|
||
savingProviderProxy.value = true
|
||
try {
|
||
const updated = await updateProvider(provider.value.id, { proxy: null })
|
||
applyProviderSnapshot(updated)
|
||
providerProxyPopoverOpen.value = false
|
||
showSuccess(legacyT('已清除提供商代理'))
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '清除代理失败'))
|
||
} finally {
|
||
savingProviderProxy.value = false
|
||
}
|
||
}
|
||
|
||
// 显示端点管理对话框
|
||
function showAddEndpointDialog() {
|
||
endpointDialogOpen.value = true
|
||
}
|
||
|
||
// ===== 端点事件处理 =====
|
||
function handleEditEndpoint(_endpoint: ProviderEndpoint) {
|
||
// 点击任何端点都打开管理对话框
|
||
endpointDialogOpen.value = true
|
||
}
|
||
|
||
async function handleEndpointChanged() {
|
||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||
emit('refresh')
|
||
}
|
||
|
||
// ===== 密钥事件处理 =====
|
||
function handleAddKey(endpoint: ProviderEndpoint) {
|
||
currentEndpoint.value = endpoint
|
||
editingKey.value = null
|
||
keyFormDialogOpen.value = true
|
||
}
|
||
|
||
// 添加密钥/账号(如果有多个端点则添加到第一个)
|
||
function handleAddKeyToFirstEndpoint() {
|
||
if (endpoints.value.length === 0) return
|
||
|
||
// OAuth 账号型提供商:打开 OAuth 账号对话框
|
||
if (isOAuthAccountProviderType(provider.value?.provider_type)) {
|
||
oauthAccountDialogOpen.value = true
|
||
} else {
|
||
// 密钥型提供商(custom/vertex_ai):打开密钥表单对话框
|
||
handleAddKey(endpoints.value[0])
|
||
}
|
||
}
|
||
|
||
function handleEditKey(endpoint: ProviderEndpoint | undefined, key: EndpointAPIKey) {
|
||
currentEndpoint.value = endpoint || null
|
||
editingKey.value = key
|
||
// OAuth 密钥使用专门的编辑对话框
|
||
if (canEditOAuthCredential(key)) {
|
||
oauthKeyEditDialogOpen.value = true
|
||
} else {
|
||
keyFormDialogOpen.value = true
|
||
}
|
||
}
|
||
|
||
function handleKeyPermissions(key: EndpointAPIKey) {
|
||
editingKey.value = key
|
||
keyPermissionsDialogOpen.value = true
|
||
}
|
||
|
||
// 复制完整密钥或认证配置
|
||
async function copyFullKey(key: EndpointAPIKey) {
|
||
const cached = revealedKeys.value.get(key.id)
|
||
if (cached) {
|
||
copyToClipboard(cached)
|
||
return
|
||
}
|
||
|
||
// 否则先获取再复制
|
||
try {
|
||
const result = await revealEndpointKey(key.id)
|
||
let textToCopy: string
|
||
|
||
if (result.auth_type === 'service_account' && result.auth_config) {
|
||
// Service Account 类型:复制 auth_config JSON
|
||
textToCopy = typeof result.auth_config === 'string'
|
||
? result.auth_config
|
||
: JSON.stringify(result.auth_config, null, 2)
|
||
} else {
|
||
// API Key 类型:复制 api_key
|
||
textToCopy = result.api_key || ''
|
||
}
|
||
|
||
revealedKeys.value.set(key.id, textToCopy)
|
||
copyToClipboard(textToCopy)
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '获取密钥失败'), legacyT('错误'))
|
||
}
|
||
}
|
||
|
||
// 下载 OAuth 凭据文件(后端统一导出,前端只负责下载)
|
||
async function downloadRefreshToken(key: EndpointAPIKey) {
|
||
try {
|
||
const data = await exportKey(key.id)
|
||
const providerType = provider.value?.provider_type || 'unknown'
|
||
const safeName = (data.email || key.name || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
|
||
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `aether_${providerType}_${safeName}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '导出失败'), legacyT('错误'))
|
||
}
|
||
}
|
||
|
||
|
||
function handleDeleteKey(key: EndpointAPIKey) {
|
||
keyToDelete.value = key
|
||
deleteKeyConfirmOpen.value = true
|
||
}
|
||
|
||
function formatDeleteKeyConfirmDescription(): string {
|
||
const keyName = keyToDelete.value?.api_key_masked || keyToDelete.value?.name || ''
|
||
return locale.value === 'en-US'
|
||
? `Delete key ${keyName}?`
|
||
: `确定要删除密钥 ${keyName} 吗?`
|
||
}
|
||
|
||
async function confirmDeleteKey() {
|
||
if (!keyToDelete.value) return
|
||
|
||
const keyId = keyToDelete.value.id
|
||
deleteKeyConfirmOpen.value = false
|
||
keyToDelete.value = null
|
||
|
||
try {
|
||
await deleteEndpointKey(keyId)
|
||
showSuccess(legacyT('密钥已删除'))
|
||
// 刷新端点列表及模型数据(删除 Key 触发自动解除模型关联)
|
||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '删除密钥失败'), legacyT('错误'))
|
||
}
|
||
}
|
||
|
||
async function handleRecoverKey(key: EndpointAPIKey) {
|
||
try {
|
||
const result = await recoverKeyHealth(key.id)
|
||
showSuccess(legacyT(result.message || 'Key已完全恢复'))
|
||
await Promise.all([loadProvider(), loadEndpoints()])
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, 'Key恢复失败'), legacyT('错误'))
|
||
}
|
||
}
|
||
|
||
async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||
if (refreshingOAuthKeyId.value) return
|
||
refreshingOAuthKeyId.value = key.id
|
||
try {
|
||
const result = await refreshProviderOAuth(key.id)
|
||
const refreshedExpiresAt = typeof result.expires_at === 'number' ? result.expires_at : null
|
||
let refreshedKey: EndpointAPIKey | null = null
|
||
// 更新本地数据
|
||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||
if (keyInList) {
|
||
keyInList.oauth_expires_at = refreshedExpiresAt
|
||
}
|
||
// 只重新加载当前 keys 页,避免整个表格刷新
|
||
if (props.providerId) {
|
||
const freshPage = await getProviderKeysPage(props.providerId, {
|
||
page: currentKeyPage.value,
|
||
page_size: keyPageSize.value,
|
||
}).catch(() => null)
|
||
if (freshPage) {
|
||
const mergedKeys = freshPage.keys.map((item) => {
|
||
if (item.id !== key.id) return item
|
||
if (refreshedExpiresAt == null) return item
|
||
if (typeof item.oauth_expires_at === 'number' && item.oauth_expires_at >= refreshedExpiresAt) {
|
||
return item
|
||
}
|
||
return { ...item, oauth_expires_at: refreshedExpiresAt }
|
||
})
|
||
providerKeys.value = mergedKeys
|
||
providerKeysTotal.value = freshPage.total
|
||
currentKeyPage.value = freshPage.page
|
||
keyPageSize.value = freshPage.page_size
|
||
syncCurrentSelections(endpoints.value, mergedKeys)
|
||
refreshedKey = mergedKeys.find(item => item.id === key.id) ?? null
|
||
}
|
||
}
|
||
const feedback = getOAuthRefreshFeedback({
|
||
accountStateRecheckAttempted: result.account_state_recheck_attempted,
|
||
accountStateRecheckError: result.account_state_recheck_error,
|
||
snapshot: refreshedKey,
|
||
})
|
||
if (feedback.tone === 'warning') {
|
||
showWarning(legacyT(feedback.message))
|
||
} else {
|
||
showSuccess(legacyT(feedback.message))
|
||
}
|
||
emit('refresh')
|
||
// Token 刷新可能激活账号并更新配额,完成后再同步一次父列表。
|
||
void autoRefreshQuotaInBackground().then((changed) => {
|
||
if (changed) emit('refresh')
|
||
})
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误'))
|
||
await Promise.all([loadProvider(), loadEndpoints()])
|
||
emit('refresh')
|
||
} finally {
|
||
refreshingOAuthKeyId.value = null
|
||
}
|
||
}
|
||
|
||
// 判断是否为账号级别的封禁(刷新 token 无法修复)
|
||
function isAccountLevelBlock(key: EndpointAPIKey): boolean {
|
||
const account = getAccountStatusDisplay(key)
|
||
const oauth = getOAuthStatusDisplay(key, countdownTick.value)
|
||
return account.blocked && !oauth?.isInvalid
|
||
}
|
||
|
||
function formatClearOAuthInvalidConfirmMessage(key: EndpointAPIKey): string {
|
||
const keyName = key.name || key.id.slice(0, 8)
|
||
return locale.value === 'en-US'
|
||
? `Confirm account "${keyName}" has completed manual verification? After clearing, the system will re-evaluate this key using the current manual switch and scheduling state.`
|
||
: `确认账号 "${keyName}" 已手动完成验证?清除后系统会按当前手动开关和调度状态重新评估该 Key。`
|
||
}
|
||
|
||
// 清除 OAuth 失效标记
|
||
async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||
if (clearingOAuthInvalidKeyId.value) return
|
||
|
||
const confirmed = await confirm({
|
||
title: legacyT('清除账号异常标记'),
|
||
message: formatClearOAuthInvalidConfirmMessage(key),
|
||
confirmText: legacyT('确认清除'),
|
||
variant: 'default',
|
||
})
|
||
if (!confirmed) return
|
||
|
||
clearingOAuthInvalidKeyId.value = key.id
|
||
try {
|
||
await clearOAuthInvalid(key.id)
|
||
showSuccess(legacyT('已清除 OAuth 异常标记'))
|
||
// 更新本地数据
|
||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||
if (keyInList) {
|
||
keyInList.oauth_invalid_at = null
|
||
keyInList.oauth_invalid_reason = null
|
||
if (keyInList.status_snapshot) {
|
||
keyInList.status_snapshot = {
|
||
...keyInList.status_snapshot,
|
||
oauth: {
|
||
...keyInList.status_snapshot.oauth,
|
||
code: 'none',
|
||
label: null,
|
||
reason: null,
|
||
invalid_at: null,
|
||
requires_reauth: false,
|
||
},
|
||
account: {
|
||
...keyInList.status_snapshot.account,
|
||
code: 'ok',
|
||
label: null,
|
||
reason: null,
|
||
blocked: false,
|
||
recoverable: false,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
await Promise.all([loadProvider(), loadEndpoints()])
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '清除失败'), legacyT('错误'))
|
||
await Promise.all([loadProvider(), loadEndpoints()])
|
||
emit('refresh')
|
||
} finally {
|
||
clearingOAuthInvalidKeyId.value = null
|
||
}
|
||
}
|
||
|
||
function codexResetCreditOutcomeFeedback(
|
||
result: Awaited<ReturnType<typeof consumeCodexResetCredit>>,
|
||
): { tone: 'success' | 'warning'; message: string } {
|
||
switch (result.outcome) {
|
||
case 'reset':
|
||
return { tone: 'success', message: '已使用 Codex 重置机会,并刷新账号配额' }
|
||
case 'already_redeemed':
|
||
return { tone: 'success', message: '本次重置请求已处理,账号配额已刷新' }
|
||
case 'nothing_to_reset':
|
||
return { tone: 'warning', message: '当前没有需要重置的 Codex 额度窗口' }
|
||
case 'no_credit':
|
||
return { tone: 'warning', message: '当前没有可用的 Codex 重置机会' }
|
||
default:
|
||
return { tone: 'warning', message: '重置请求已返回,但结果类型未知,请查看最新账号配额' }
|
||
}
|
||
}
|
||
|
||
async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
|
||
if (!canConsumeCodexResetCredit(key)) return
|
||
|
||
const earliest = getVisibleCodexResetCreditItems(key)[0]
|
||
const detailMessage = earliest
|
||
? `\n当前最早过期项:${earliest.displayKey},${formatCodexResetCreditExpiresAt(earliest.expiresAt)} 过期。`
|
||
: ''
|
||
const confirmed = await confirm({
|
||
title: legacyT('确认使用 Codex 重置机会'),
|
||
message: `${legacyT('将消耗 1 次 Codex 重置机会。操作完成后会重新刷新账号配额状态。')}${detailMessage}`,
|
||
confirmText: legacyT('确认重置'),
|
||
cancelText: legacyT('取消'),
|
||
variant: 'warning',
|
||
})
|
||
if (!confirmed) return
|
||
|
||
consumingCodexResetCreditKeyId.value = key.id
|
||
try {
|
||
const idempotencyKey = createCodexResetCreditIdempotencyKey()
|
||
const result = await consumeCodexResetCredit(key.id, {
|
||
idempotency_key: idempotencyKey,
|
||
})
|
||
applyQuotaResults([{
|
||
key_id: result.key_id,
|
||
status: result.refresh_status === 'success' ? 'success' : result.status,
|
||
metadata: result.metadata,
|
||
quota_snapshot: result.quota_snapshot,
|
||
}])
|
||
|
||
const feedback = codexResetCreditOutcomeFeedback(result)
|
||
if (feedback.tone === 'success') {
|
||
showSuccess(legacyT(feedback.message))
|
||
} else {
|
||
showWarning(legacyT(feedback.message))
|
||
}
|
||
if (result.refresh_status === 'failed') {
|
||
showWarning(legacyT(result.refresh_error || '重置请求已处理,但最新配额刷新失败'))
|
||
}
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误'))
|
||
await Promise.all([loadProvider(), loadEndpoints()])
|
||
emit('refresh')
|
||
} finally {
|
||
consumingCodexResetCreditKeyId.value = null
|
||
}
|
||
}
|
||
|
||
// Codex / Gemini CLI / Antigravity / Kiro / Windsurf / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
|
||
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
|
||
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
|
||
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
|
||
|
||
function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefined): boolean {
|
||
if (!quota) return false
|
||
return Boolean(
|
||
(typeof quota.code === 'string' && quota.code.trim().toLowerCase() !== 'unknown')
|
||
|| quota.updated_at != null
|
||
|| quota.observed_at != null
|
||
|| quota.usage_ratio != null
|
||
|| (Array.isArray(quota.windows) && quota.windows.length > 0)
|
||
|| quota.credits,
|
||
)
|
||
}
|
||
|
||
function getQuotaSnapshotForProvider(
|
||
key: EndpointAPIKey,
|
||
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
|
||
): QuotaStatusSnapshot | null {
|
||
const quota = key.status_snapshot?.quota
|
||
if (!quota) return null
|
||
|
||
const snapshotProviderType = quota.provider_type?.trim().toLowerCase()
|
||
if (snapshotProviderType) {
|
||
return snapshotProviderType === providerType ? quota : null
|
||
}
|
||
|
||
return quotaSnapshotHasDisplayData(quota) ? quota : null
|
||
}
|
||
|
||
function getQuotaSnapshotUpdatedAt(quota: QuotaStatusSnapshot | null | undefined): number | undefined {
|
||
const updatedAt = quota?.updated_at ?? quota?.observed_at
|
||
return typeof updatedAt === 'number' ? updatedAt : undefined
|
||
}
|
||
|
||
function getQuotaWindow(
|
||
quota: QuotaStatusSnapshot | null | undefined,
|
||
code: string,
|
||
): QuotaWindowSnapshot | null {
|
||
const windows = quota?.windows
|
||
if (!Array.isArray(windows)) return null
|
||
return windows.find(window => String(window?.code || '').trim().toLowerCase() === code.trim().toLowerCase()) ?? null
|
||
}
|
||
|
||
function getQuotaWindowUsedPercent(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||
if (!window) return undefined
|
||
if (typeof window.used_ratio === 'number') {
|
||
return Math.max(Math.min(window.used_ratio * 100, 100), 0)
|
||
}
|
||
if (typeof window.remaining_ratio === 'number') {
|
||
return Math.max(Math.min((1 - window.remaining_ratio) * 100, 100), 0)
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function getQuotaWindowRemainingPercent(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||
if (!window) return undefined
|
||
if (typeof window.remaining_ratio === 'number') {
|
||
return Math.max(Math.min(window.remaining_ratio * 100, 100), 0)
|
||
}
|
||
if (typeof window.used_ratio === 'number') {
|
||
return Math.max(Math.min((1 - window.used_ratio) * 100, 100), 0)
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function getQuotaWindowResetAt(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||
return typeof window?.reset_at === 'number' ? window.reset_at : undefined
|
||
}
|
||
|
||
function getQuotaWindowResetSeconds(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
||
return typeof window?.reset_seconds === 'number' ? window.reset_seconds : undefined
|
||
}
|
||
|
||
function getQuotaWindowByScope(
|
||
quota: QuotaStatusSnapshot | null | undefined,
|
||
scope: string,
|
||
): QuotaWindowSnapshot[] {
|
||
const windows = quota?.windows
|
||
if (!Array.isArray(windows)) return []
|
||
return windows.filter(window => String(window?.scope || '').trim().toLowerCase() === scope.trim().toLowerCase())
|
||
}
|
||
|
||
function getQuotaWindowLiveResetSeconds(
|
||
quota: QuotaStatusSnapshot | null | undefined,
|
||
window: QuotaWindowSnapshot | null | undefined,
|
||
): number | null {
|
||
if (!window) return null
|
||
|
||
const now = Math.floor(Date.now() / 1000)
|
||
if (typeof window.reset_at === 'number') {
|
||
return Math.max(window.reset_at - now, 0)
|
||
}
|
||
|
||
if (typeof window.reset_seconds === 'number') {
|
||
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||
const elapsed = typeof updatedAt === 'number' ? Math.max(now - updatedAt, 0) : 0
|
||
return Math.max(window.reset_seconds - elapsed, 0)
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function copyCodexNumberField(
|
||
target: CodexUpstreamMetadata,
|
||
source: CodexUpstreamMetadata,
|
||
field: keyof CodexUpstreamMetadata,
|
||
) {
|
||
const value = source[field]
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
;(target[field] as number | undefined) = value
|
||
}
|
||
}
|
||
|
||
function getCodexQuotaDisplayFromMetadata(metadata: CodexUpstreamMetadata | null | undefined): CodexUpstreamMetadata | null {
|
||
if (!metadata) return null
|
||
|
||
const display: CodexUpstreamMetadata = {}
|
||
if (metadata.plan_type) display.plan_type = metadata.plan_type
|
||
|
||
const numberFields: (keyof CodexUpstreamMetadata)[] = [
|
||
'updated_at',
|
||
'primary_used_percent',
|
||
'primary_reset_seconds',
|
||
'primary_reset_after_seconds',
|
||
'primary_reset_at',
|
||
'primary_window_minutes',
|
||
'secondary_used_percent',
|
||
'secondary_reset_seconds',
|
||
'secondary_reset_after_seconds',
|
||
'secondary_reset_at',
|
||
'secondary_window_minutes',
|
||
'spark_primary_used_percent',
|
||
'spark_primary_reset_seconds',
|
||
'spark_primary_reset_after_seconds',
|
||
'spark_primary_reset_at',
|
||
'spark_primary_window_minutes',
|
||
'spark_secondary_used_percent',
|
||
'spark_secondary_reset_seconds',
|
||
'spark_secondary_reset_after_seconds',
|
||
'spark_secondary_reset_at',
|
||
'spark_secondary_window_minutes',
|
||
'credits_balance',
|
||
]
|
||
numberFields.forEach(field => copyCodexNumberField(display, metadata, field))
|
||
if (metadata.has_credits !== undefined) display.has_credits = metadata.has_credits
|
||
if (metadata.reset_credits) display.reset_credits = metadata.reset_credits
|
||
|
||
return Object.keys(display).length > 0 ? display : null
|
||
}
|
||
|
||
function getCodexQuotaDisplayFromSnapshot(quota: QuotaStatusSnapshot | null | undefined): CodexUpstreamMetadata | null {
|
||
if (!quota) return null
|
||
|
||
const display: CodexUpstreamMetadata = {}
|
||
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||
if (quota.plan_type) display.plan_type = quota.plan_type
|
||
|
||
const primaryWindow = getCodexPrimaryQuotaWindow(quota.windows)
|
||
const primaryUsedPercent = getQuotaWindowUsedPercent(primaryWindow)
|
||
if (primaryUsedPercent !== undefined) display.primary_used_percent = primaryUsedPercent
|
||
const primaryResetAt = getQuotaWindowResetAt(primaryWindow)
|
||
if (primaryResetAt !== undefined) display.primary_reset_at = primaryResetAt
|
||
const primaryResetSeconds = getQuotaWindowResetSeconds(primaryWindow)
|
||
if (primaryResetSeconds !== undefined) display.primary_reset_seconds = primaryResetSeconds
|
||
if (typeof primaryWindow?.window_minutes === 'number') {
|
||
display.primary_window_minutes = primaryWindow.window_minutes
|
||
}
|
||
|
||
const secondaryWindow = getQuotaWindow(quota, '5h')
|
||
const secondaryUsedPercent = getQuotaWindowUsedPercent(secondaryWindow)
|
||
if (secondaryUsedPercent !== undefined) display.secondary_used_percent = secondaryUsedPercent
|
||
const secondaryResetAt = getQuotaWindowResetAt(secondaryWindow)
|
||
if (secondaryResetAt !== undefined) display.secondary_reset_at = secondaryResetAt
|
||
const secondaryResetSeconds = getQuotaWindowResetSeconds(secondaryWindow)
|
||
if (secondaryResetSeconds !== undefined) display.secondary_reset_seconds = secondaryResetSeconds
|
||
if (typeof secondaryWindow?.window_minutes === 'number') {
|
||
display.secondary_window_minutes = secondaryWindow.window_minutes
|
||
}
|
||
|
||
const sparkPrimaryWindow = getQuotaWindow(quota, 'spark_5h')
|
||
const sparkPrimaryUsedPercent = getQuotaWindowUsedPercent(sparkPrimaryWindow)
|
||
if (sparkPrimaryUsedPercent !== undefined) display.spark_primary_used_percent = sparkPrimaryUsedPercent
|
||
const sparkPrimaryResetAt = getQuotaWindowResetAt(sparkPrimaryWindow)
|
||
if (sparkPrimaryResetAt !== undefined) display.spark_primary_reset_at = sparkPrimaryResetAt
|
||
const sparkPrimaryResetSeconds = getQuotaWindowResetSeconds(sparkPrimaryWindow)
|
||
if (sparkPrimaryResetSeconds !== undefined) display.spark_primary_reset_seconds = sparkPrimaryResetSeconds
|
||
if (typeof sparkPrimaryWindow?.window_minutes === 'number') {
|
||
display.spark_primary_window_minutes = sparkPrimaryWindow.window_minutes
|
||
}
|
||
|
||
const sparkSecondaryWindow = getQuotaWindow(quota, 'spark_weekly')
|
||
const sparkSecondaryUsedPercent = getQuotaWindowUsedPercent(sparkSecondaryWindow)
|
||
if (sparkSecondaryUsedPercent !== undefined) display.spark_secondary_used_percent = sparkSecondaryUsedPercent
|
||
const sparkSecondaryResetAt = getQuotaWindowResetAt(sparkSecondaryWindow)
|
||
if (sparkSecondaryResetAt !== undefined) display.spark_secondary_reset_at = sparkSecondaryResetAt
|
||
const sparkSecondaryResetSeconds = getQuotaWindowResetSeconds(sparkSecondaryWindow)
|
||
if (sparkSecondaryResetSeconds !== undefined) display.spark_secondary_reset_seconds = sparkSecondaryResetSeconds
|
||
if (typeof sparkSecondaryWindow?.window_minutes === 'number') {
|
||
display.spark_secondary_window_minutes = sparkSecondaryWindow.window_minutes
|
||
}
|
||
if (quota.reset_credits) display.reset_credits = quota.reset_credits
|
||
|
||
return Object.keys(display).length > 0 ? display : null
|
||
}
|
||
|
||
function codexDisplayHasResetCredits(display: CodexUpstreamMetadata | null | undefined): boolean {
|
||
const count = display?.reset_credits?.available_count
|
||
return typeof count === 'number' && Number.isFinite(count)
|
||
}
|
||
|
||
function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null {
|
||
const snapshotDisplay = getCodexQuotaDisplayFromSnapshot(getQuotaSnapshotForProvider(key, 'codex'))
|
||
const metadataDisplay = getCodexQuotaDisplayFromMetadata(key.upstream_metadata?.codex)
|
||
return mergeCodexQuotaDisplays(snapshotDisplay, metadataDisplay)
|
||
}
|
||
|
||
function getCodexPrimaryQuotaLabel(key: EndpointAPIKey): string {
|
||
return getCodexQuotaWindowLimitLabel({
|
||
code: 'weekly',
|
||
label: '周',
|
||
window_minutes: getCodexQuotaDisplay(key)?.primary_window_minutes,
|
||
}) || '周限额'
|
||
}
|
||
|
||
function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||
const codex = getCodexQuotaDisplay(key)
|
||
return !!codex && (
|
||
codex.primary_used_percent !== undefined
|
||
|| codex.secondary_used_percent !== undefined
|
||
|| codex.spark_primary_used_percent !== undefined
|
||
|| codex.spark_secondary_used_percent !== undefined
|
||
|| codexDisplayHasResetCredits(codex)
|
||
)
|
||
}
|
||
|
||
function hasCodexSparkQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||
const codex = getCodexQuotaDisplay(key)
|
||
return !!codex && (
|
||
codex.spark_primary_used_percent !== undefined
|
||
|| codex.spark_secondary_used_percent !== undefined
|
||
)
|
||
}
|
||
|
||
function getCodexResetCreditsDisplay(key: EndpointAPIKey): QuotaResetCreditsSnapshot | null {
|
||
return getCodexQuotaDisplay(key)?.reset_credits ?? null
|
||
}
|
||
|
||
function getCodexResetCreditAvailableCount(key: EndpointAPIKey): number | null {
|
||
return getCodexResetCreditAvailableCountFromSnapshot(getCodexResetCreditsDisplay(key))
|
||
}
|
||
|
||
function formatCodexResetCreditCount(key: EndpointAPIKey): string {
|
||
return formatCodexResetCreditCountLabel(getCodexResetCreditAvailableCount(key))
|
||
}
|
||
|
||
function getVisibleCodexResetCreditItems(key: EndpointAPIKey) {
|
||
return getVisibleCodexResetCreditItemsFromSnapshot(getCodexResetCreditsDisplay(key))
|
||
}
|
||
|
||
function canConsumeCodexResetCredit(key: EndpointAPIKey): boolean {
|
||
return provider.value?.provider_type === 'codex'
|
||
&& getCodexResetCreditAvailableCount(key) !== null
|
||
&& (getCodexResetCreditAvailableCount(key) ?? 0) > 0
|
||
&& !consumingCodexResetCreditKeyId.value
|
||
}
|
||
|
||
function getKiroQuotaDisplay(key: EndpointAPIKey): KiroUpstreamMetadata | null {
|
||
const quota = getQuotaSnapshotForProvider(key, 'kiro')
|
||
if (!quota) return null
|
||
|
||
const display: KiroUpstreamMetadata = {}
|
||
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||
if (quota.plan_type) display.subscription_title = quota.plan_type
|
||
|
||
if (String(quota.code || '').trim().toLowerCase() === 'banned') {
|
||
display.is_banned = true
|
||
if (quota.reason) display.ban_reason = quota.reason
|
||
if (updatedAt !== undefined) display.banned_at = updatedAt
|
||
}
|
||
|
||
const usageWindow =
|
||
getQuotaWindow(quota, 'usage')
|
||
?? getQuotaWindowByScope(quota, 'account')[0]
|
||
?? null
|
||
if (usageWindow) {
|
||
const usedPercent = getQuotaWindowUsedPercent(usageWindow)
|
||
if (usedPercent !== undefined) display.usage_percentage = usedPercent
|
||
if (typeof usageWindow.used_value === 'number') display.current_usage = usageWindow.used_value
|
||
if (typeof usageWindow.limit_value === 'number') display.usage_limit = usageWindow.limit_value
|
||
if (typeof usageWindow.remaining_value === 'number') display.remaining = usageWindow.remaining_value
|
||
|
||
const nextResetAt =
|
||
getQuotaWindowResetAt(usageWindow)
|
||
?? (() => {
|
||
const resetSeconds = getQuotaWindowResetSeconds(usageWindow)
|
||
if (updatedAt === undefined || resetSeconds === undefined) return undefined
|
||
return updatedAt + resetSeconds
|
||
})()
|
||
if (nextResetAt !== undefined) display.next_reset_at = nextResetAt
|
||
}
|
||
|
||
return Object.keys(display).length > 0 ? display : null
|
||
}
|
||
|
||
function hasKiroQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||
const kiro = getKiroQuotaDisplay(key)
|
||
return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined)
|
||
}
|
||
|
||
type GrokQuotaDisplay = GrokUpstreamMetadata & {
|
||
usage_percentage?: number
|
||
usage_limit?: number
|
||
current_usage?: number
|
||
remaining?: number
|
||
next_reset_at?: number
|
||
}
|
||
|
||
function getGrokQuotaDisplay(key: EndpointAPIKey): GrokQuotaDisplay | null {
|
||
const quota = getQuotaSnapshotForProvider(key, 'grok')
|
||
if (!quota) return null
|
||
|
||
const display: GrokQuotaDisplay = {}
|
||
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||
if (quota.plan_type) display.plan_type = quota.plan_type
|
||
if (quota.pool_tier) display.pool_tier = quota.pool_tier
|
||
|
||
const code = String(quota.code || '').trim().toLowerCase()
|
||
if (code === 'banned' || code === 'forbidden') {
|
||
display.is_banned = true
|
||
if (quota.reason) display.ban_reason = quota.reason
|
||
}
|
||
|
||
const usageWindow =
|
||
getQuotaWindow(quota, 'usage')
|
||
?? getQuotaWindowByScope(quota, 'account')[0]
|
||
?? getQuotaWindowByScope(quota, 'model')
|
||
.map(window => ({
|
||
window,
|
||
remainingPercent: getQuotaWindowRemainingPercent(window),
|
||
}))
|
||
.filter((item): item is { window: QuotaWindowSnapshot, remainingPercent: number } => item.remainingPercent !== undefined)
|
||
.sort((a, b) => a.remainingPercent - b.remainingPercent)[0]?.window
|
||
?? null
|
||
if (usageWindow) {
|
||
const usedPercent = getQuotaWindowUsedPercent(usageWindow)
|
||
if (usedPercent !== undefined) display.usage_percentage = usedPercent
|
||
if (typeof usageWindow.used_value === 'number') display.current_usage = usageWindow.used_value
|
||
if (typeof usageWindow.limit_value === 'number') display.usage_limit = usageWindow.limit_value
|
||
if (typeof usageWindow.remaining_value === 'number') display.remaining = usageWindow.remaining_value
|
||
|
||
const nextResetAt =
|
||
getQuotaWindowResetAt(usageWindow)
|
||
?? (() => {
|
||
const resetSeconds = getQuotaWindowResetSeconds(usageWindow)
|
||
if (updatedAt === undefined || resetSeconds === undefined) return undefined
|
||
return updatedAt + resetSeconds
|
||
})()
|
||
if (nextResetAt !== undefined) display.next_reset_at = nextResetAt
|
||
}
|
||
|
||
return Object.keys(display).length > 0 ? display : null
|
||
}
|
||
|
||
type WindsurfQuotaDisplay = WindsurfUpstreamMetadata & {
|
||
daily_used_percent?: number
|
||
weekly_used_percent?: number
|
||
}
|
||
|
||
function getWindsurfQuotaDisplay(key: EndpointAPIKey): WindsurfQuotaDisplay | null {
|
||
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
|
||
const upstream = key.upstream_metadata?.windsurf
|
||
if (!quota && !upstream) return null
|
||
|
||
const display: WindsurfQuotaDisplay = {}
|
||
const updatedAt = getQuotaSnapshotUpdatedAt(quota) ?? upstream?.updated_at
|
||
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||
if (quota?.plan_type) display.plan_name = quota.plan_type
|
||
else if (upstream?.plan_name) display.plan_name = upstream.plan_name
|
||
if (quota?.reason) display.last_error = quota.reason
|
||
else if (upstream?.last_error) display.last_error = upstream.last_error
|
||
if (typeof quota?.allowed_models_count === 'number') display.allowed_models_count = quota.allowed_models_count
|
||
else if (typeof upstream?.allowed_models_count === 'number') display.allowed_models_count = upstream.allowed_models_count
|
||
if (quota?.rate_limit) display.rate_limit = quota.rate_limit
|
||
else if (upstream?.rate_limit) display.rate_limit = upstream.rate_limit
|
||
if (Array.isArray(upstream?.models)) display.models = upstream.models
|
||
|
||
const dailyWindow = getQuotaWindow(quota, 'daily')
|
||
const dailyRemaining = getQuotaWindowRemainingPercent(dailyWindow)
|
||
const dailyUsed = getQuotaWindowUsedPercent(dailyWindow)
|
||
if (dailyRemaining !== undefined) display.daily_remaining_percent = dailyRemaining
|
||
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_remaining_percent = upstream.daily_remaining_percent
|
||
if (dailyUsed !== undefined) display.daily_used_percent = dailyUsed
|
||
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_used_percent = Math.max(100 - upstream.daily_remaining_percent, 0)
|
||
const dailyResetAt = getQuotaWindowResetAt(dailyWindow)
|
||
if (dailyResetAt !== undefined) display.daily_reset_at = dailyResetAt
|
||
else if (typeof upstream?.daily_reset_at === 'number') display.daily_reset_at = upstream.daily_reset_at
|
||
|
||
const weeklyWindow = getQuotaWindow(quota, 'weekly')
|
||
const weeklyRemaining = getQuotaWindowRemainingPercent(weeklyWindow)
|
||
const weeklyUsed = getQuotaWindowUsedPercent(weeklyWindow)
|
||
if (weeklyRemaining !== undefined) display.weekly_remaining_percent = weeklyRemaining
|
||
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_remaining_percent = upstream.weekly_remaining_percent
|
||
if (weeklyUsed !== undefined) display.weekly_used_percent = weeklyUsed
|
||
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_used_percent = Math.max(100 - upstream.weekly_remaining_percent, 0)
|
||
const weeklyResetAt = getQuotaWindowResetAt(weeklyWindow)
|
||
if (weeklyResetAt !== undefined) display.weekly_reset_at = weeklyResetAt
|
||
else if (typeof upstream?.weekly_reset_at === 'number') display.weekly_reset_at = upstream.weekly_reset_at
|
||
|
||
const promptWindow = getQuotaWindow(quota, 'prompt')
|
||
if (typeof promptWindow?.used_value === 'number') display.prompt_used = promptWindow.used_value
|
||
else if (typeof upstream?.prompt_used === 'number') display.prompt_used = upstream.prompt_used
|
||
if (typeof promptWindow?.limit_value === 'number') display.prompt_limit = promptWindow.limit_value
|
||
else if (typeof upstream?.prompt_limit === 'number') display.prompt_limit = upstream.prompt_limit
|
||
if (typeof promptWindow?.remaining_value === 'number') display.prompt_remaining = promptWindow.remaining_value
|
||
else if (typeof upstream?.prompt_remaining === 'number') display.prompt_remaining = upstream.prompt_remaining
|
||
|
||
const flexWindow = getQuotaWindow(quota, 'flex')
|
||
if (typeof flexWindow?.used_value === 'number') display.flex_used = flexWindow.used_value
|
||
else if (typeof upstream?.flex_used === 'number') display.flex_used = upstream.flex_used
|
||
if (typeof flexWindow?.limit_value === 'number') display.flex_limit = flexWindow.limit_value
|
||
else if (typeof upstream?.flex_limit === 'number') display.flex_limit = upstream.flex_limit
|
||
if (typeof flexWindow?.remaining_value === 'number') display.flex_remaining = flexWindow.remaining_value
|
||
else if (typeof upstream?.flex_remaining === 'number') display.flex_remaining = upstream.flex_remaining
|
||
|
||
return Object.keys(display).length > 0 ? display : null
|
||
}
|
||
|
||
function hasGrokQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||
const grok = getGrokQuotaDisplay(key)
|
||
return !!grok && (grok.usage_percentage !== undefined || grok.usage_limit !== undefined)
|
||
}
|
||
|
||
function hasWindsurfQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||
const windsurf = getWindsurfQuotaDisplay(key)
|
||
return !!windsurf && (
|
||
windsurf.daily_remaining_percent !== undefined
|
||
|| windsurf.weekly_remaining_percent !== undefined
|
||
|| windsurf.prompt_limit !== undefined
|
||
|| windsurf.flex_limit !== undefined
|
||
|| windsurf.allowed_models_count !== undefined
|
||
|| windsurf.rate_limit !== undefined
|
||
|| !!windsurf.last_error
|
||
|| (Array.isArray(windsurf.models) && windsurf.models.length > 0)
|
||
)
|
||
}
|
||
|
||
function isWindsurfUnavailableKey(key: EndpointAPIKey): boolean {
|
||
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
|
||
return code === 'banned' || code === 'forbidden' || code === 'quarantined'
|
||
}
|
||
|
||
function getPositiveQuotaNumber(value: unknown): number | undefined {
|
||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
|
||
}
|
||
|
||
function windsurfCooldownHasPositiveReset(key: EndpointAPIKey): boolean {
|
||
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
|
||
const rateLimit = quota?.rate_limit
|
||
if (rateLimit && typeof rateLimit === 'object') {
|
||
const retryAfterMs =
|
||
getPositiveQuotaNumber(rateLimit.retry_after_ms)
|
||
?? getPositiveQuotaNumber(rateLimit.retryAfterMs)
|
||
if (retryAfterMs !== undefined) return true
|
||
}
|
||
|
||
const rateLimitWindow = getQuotaWindow(quota, 'rate_limit')
|
||
return (
|
||
getPositiveQuotaNumber(rateLimitWindow?.reset_seconds) !== undefined
|
||
|| getPositiveQuotaNumber(rateLimitWindow?.reset_at) !== undefined
|
||
)
|
||
}
|
||
|
||
function isWindsurfExhaustedKey(key: EndpointAPIKey): boolean {
|
||
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
|
||
if (code === 'cooldown') return windsurfCooldownHasPositiveReset(key)
|
||
return code === 'exhausted' || code === 'rate_limited' || code === 'rate_limit'
|
||
}
|
||
|
||
function getWindsurfQuotaStatusLabel(key: EndpointAPIKey): string {
|
||
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
|
||
const label = quota?.label?.trim()
|
||
if (label) return label
|
||
const code = String(quota?.code || '').trim().toLowerCase()
|
||
if (code === 'cooldown') return legacyT('冷却中')
|
||
return code === 'rate_limited' || code === 'rate_limit' ? legacyT('速率受限') : legacyT('额度耗尽')
|
||
}
|
||
|
||
function getWindsurfModelPreview(key: EndpointAPIKey): string | null {
|
||
const models = getWindsurfQuotaDisplay(key)?.models
|
||
if (!Array.isArray(models) || models.length === 0) return null
|
||
return models
|
||
.slice(0, 3)
|
||
.map(model => (model.label || model.model_uid || '').trim())
|
||
.filter(Boolean)
|
||
.join(' / ') || null
|
||
}
|
||
|
||
function hasFiniteNumber(value: unknown): value is number {
|
||
return typeof value === 'number' && Number.isFinite(value)
|
||
}
|
||
|
||
function hasWindsurfPromptQuota(key: EndpointAPIKey): boolean {
|
||
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.prompt_limit)
|
||
}
|
||
|
||
function hasWindsurfFlexQuota(key: EndpointAPIKey): boolean {
|
||
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.flex_limit)
|
||
}
|
||
|
||
function hasWindsurfModelCount(key: EndpointAPIKey): boolean {
|
||
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.allowed_models_count)
|
||
}
|
||
|
||
function hasWindsurfModelPreview(key: EndpointAPIKey): boolean {
|
||
return !!getWindsurfModelPreview(key)
|
||
}
|
||
|
||
type ChatGPTWebQuotaDisplay = ChatGPTWebUpstreamMetadata & {
|
||
image_quota_remaining_percent?: number
|
||
image_quota_used_percent?: number
|
||
}
|
||
|
||
function getChatGPTWebQuotaDisplay(key: EndpointAPIKey): ChatGPTWebQuotaDisplay | null {
|
||
const quota = getQuotaSnapshotForProvider(key, 'chatgpt_web')
|
||
if (!quota) return null
|
||
|
||
const display: ChatGPTWebQuotaDisplay = {}
|
||
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||
if (quota.plan_type) display.plan_type = quota.plan_type
|
||
if (quota.code === 'exhausted' || quota.code === 'banned') display.image_quota_blocked = true
|
||
|
||
const imageWindow =
|
||
getQuotaWindow(quota, 'image_gen')
|
||
?? getQuotaWindowByScope(quota, 'account')[0]
|
||
?? null
|
||
if (imageWindow) {
|
||
const remainingValue = typeof imageWindow.remaining_value === 'number' ? imageWindow.remaining_value : undefined
|
||
const limitValue = typeof imageWindow.limit_value === 'number' ? imageWindow.limit_value : undefined
|
||
const usedValue = typeof imageWindow.used_value === 'number' ? imageWindow.used_value : undefined
|
||
const remainingPercent = getQuotaWindowRemainingPercent(imageWindow)
|
||
const usedPercent = getQuotaWindowUsedPercent(imageWindow)
|
||
|
||
if (remainingValue !== undefined) display.image_quota_remaining = remainingValue
|
||
if (limitValue !== undefined) display.image_quota_total = limitValue
|
||
if (usedValue !== undefined) display.image_quota_used = usedValue
|
||
if (remainingPercent !== undefined) display.image_quota_remaining_percent = remainingPercent
|
||
if (usedPercent !== undefined) display.image_quota_used_percent = usedPercent
|
||
if (typeof imageWindow.reset_at === 'number') display.image_quota_reset_at = imageWindow.reset_at
|
||
if (typeof imageWindow.reset_seconds === 'number') {
|
||
const resetAt = updatedAt === undefined ? undefined : updatedAt + imageWindow.reset_seconds
|
||
if (resetAt !== undefined && display.image_quota_reset_at === undefined) {
|
||
display.image_quota_reset_at = resetAt
|
||
}
|
||
}
|
||
}
|
||
|
||
return Object.keys(display).length > 0 ? display : null
|
||
}
|
||
|
||
function hasChatGPTWebQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||
const display = getChatGPTWebQuotaDisplay(key)
|
||
return !!display && (
|
||
display.image_quota_remaining_percent !== undefined
|
||
|| display.image_quota_total !== undefined
|
||
|| display.image_quota_used !== undefined
|
||
)
|
||
}
|
||
|
||
function getChatGPTWebQuotaUsedPercent(key: EndpointAPIKey): number {
|
||
const display = getChatGPTWebQuotaDisplay(key)
|
||
if (!display) return 0
|
||
if (typeof display.image_quota_used_percent === 'number') return display.image_quota_used_percent
|
||
if (typeof display.image_quota_remaining_percent === 'number') {
|
||
return Math.max(100 - display.image_quota_remaining_percent, 0)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
function getChatGPTWebQuotaRemainingPercent(key: EndpointAPIKey): number {
|
||
const display = getChatGPTWebQuotaDisplay(key)
|
||
if (!display) return 0
|
||
if (typeof display.image_quota_remaining_percent === 'number') return display.image_quota_remaining_percent
|
||
if (typeof display.image_quota_used_percent === 'number') {
|
||
return Math.max(100 - display.image_quota_used_percent, 0)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
function formatChatGPTWebUsage(value: number | null | undefined): string {
|
||
if (value === undefined || value === null) return '-'
|
||
if (Math.abs(value - Math.round(value)) < 1e-6) {
|
||
return String(Math.round(value))
|
||
}
|
||
return value.toFixed(1)
|
||
}
|
||
|
||
function isKiroBannedKey(key: EndpointAPIKey): boolean {
|
||
const quota = getQuotaSnapshotForProvider(key, 'kiro')
|
||
return String(quota?.code || '').trim().toLowerCase() === 'banned'
|
||
}
|
||
|
||
// 格式化封禁/禁止时间(后端返回秒级时间戳,Kiro/Antigravity 通用)
|
||
function formatBanTimestamp(timestamp: number | undefined): string {
|
||
if (!timestamp) return ''
|
||
const date = new Date(timestamp * 1000)
|
||
return date.toLocaleString('zh-CN', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
function isAntigravityForbiddenKey(key: EndpointAPIKey): boolean {
|
||
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||
return String(quota?.code || '').trim().toLowerCase() === 'forbidden'
|
||
}
|
||
|
||
function getAntigravityForbiddenReason(key: EndpointAPIKey): string | undefined {
|
||
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||
return quota?.reason || undefined
|
||
}
|
||
|
||
function getAntigravityForbiddenAt(key: EndpointAPIKey): number | undefined {
|
||
return getQuotaSnapshotUpdatedAt(getQuotaSnapshotForProvider(key, 'antigravity'))
|
||
}
|
||
|
||
function getAntigravityQuotaUpdatedAt(key: EndpointAPIKey): number | undefined {
|
||
return getQuotaSnapshotUpdatedAt(getQuotaSnapshotForProvider(key, 'antigravity'))
|
||
}
|
||
|
||
// 格式化 Kiro 更新时间
|
||
const formatKiroUpdatedAt = formatUpdatedAt
|
||
|
||
// 格式化 Kiro 使用量(带单位)
|
||
function formatKiroUsage(value: number | undefined): string {
|
||
if (value === undefined || value === null) return '-'
|
||
const normalized = Number(value)
|
||
if (!Number.isFinite(normalized)) return '-'
|
||
if (normalized >= 1000) return formatCompactNumber(normalized, { fractionDigits: 1 })
|
||
return normalized.toFixed(1)
|
||
}
|
||
|
||
// 格式化 Kiro 重置时间
|
||
function formatKiroResetTime(timestamp: number | undefined): string {
|
||
if (!timestamp) return ''
|
||
// timestamp 可能是毫秒或秒,需要判断
|
||
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000
|
||
const now = Date.now()
|
||
const diff = ts - now
|
||
|
||
if (diff <= 0) {
|
||
return legacyT('已重置')
|
||
}
|
||
|
||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||
|
||
if (days > 0) {
|
||
return locale.value === 'en-US' ? `${days}d ${hours}h later` : `${days}天${hours}小时后`
|
||
}
|
||
|
||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||
if (hours > 0) {
|
||
return locale.value === 'en-US' ? `${hours}h ${minutes}m later` : `${hours}小时${minutes}分钟后`
|
||
}
|
||
|
||
return locale.value === 'en-US' ? `${minutes}m later` : `${minutes}分钟后`
|
||
}
|
||
|
||
// 格式化 Kiro 订阅类型显示
|
||
function formatKiroSubscription(title: string | undefined): string {
|
||
if (!title) return ''
|
||
// 简化显示:KIRO PRO+ -> Pro+, KIRO FREE -> Free(首字母大写,与 Codex 保持一致)
|
||
const upper = title.toUpperCase()
|
||
if (upper.includes('POWER')) return 'Power'
|
||
if (upper.includes('PRO+')) return 'Pro+'
|
||
if (upper.includes('PRO')) return 'Pro'
|
||
if (upper.includes('FREE')) return 'Free'
|
||
return title
|
||
}
|
||
|
||
function getKiroSubscriptionTitle(key: EndpointAPIKey): string | undefined {
|
||
return getKiroQuotaDisplay(key)?.subscription_title
|
||
}
|
||
|
||
function getKiroSubscriptionBadgeLabel(key: EndpointAPIKey): string {
|
||
return formatKiroSubscription(getKiroSubscriptionTitle(key))
|
||
}
|
||
|
||
function shouldShowKiroSubscriptionBadge(key: EndpointAPIKey): boolean {
|
||
if (provider.value?.provider_type !== 'kiro') return false
|
||
|
||
const kiroLabel = getKiroSubscriptionBadgeLabel(key)
|
||
if (!kiroLabel) return false
|
||
|
||
const oauthPlanLabel = formatOAuthPlanType(key.oauth_plan_type)
|
||
if (!oauthPlanLabel) return true
|
||
|
||
return oauthPlanLabel.trim().toLowerCase() !== kiroLabel.trim().toLowerCase()
|
||
}
|
||
|
||
function shouldAutoRefreshCodexQuota(): boolean {
|
||
if (provider.value?.provider_type !== 'codex') return false
|
||
const now = Math.floor(Date.now() / 1000)
|
||
|
||
for (const { key } of allKeys.value) {
|
||
if (!key.is_active) continue
|
||
|
||
if (isTokenExpiringSoon(key, now)) return true
|
||
|
||
// reset-credit 独立于 Token 刷新;这里只按账号配额缓存决定是否后台更新
|
||
if (!hasCodexQuotaDisplayData(key)) {
|
||
return true
|
||
}
|
||
// 配额数据超过 5 分钟未更新,也触发刷新
|
||
const updatedAt = getCodexQuotaDisplay(key)?.updated_at
|
||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// 检查 OAuth Token 是否即将过期(Codex / Gemini CLI / Antigravity / Kiro / Windsurf / ChatGPT Web)
|
||
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
|
||
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
|
||
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
|
||
return false
|
||
}
|
||
return typeof key.oauth_expires_at === 'number'
|
||
&& (key.oauth_expires_at - now) <= AUTO_TOKEN_REFRESH_SKEW_SECONDS
|
||
}
|
||
|
||
function shouldAutoRefreshAntigravityQuota(): boolean {
|
||
if (provider.value?.provider_type !== 'antigravity') return false
|
||
const now = Math.floor(Date.now() / 1000)
|
||
|
||
for (const { key } of allKeys.value) {
|
||
if (!key.is_active) continue
|
||
|
||
if (isTokenExpiringSoon(key, now)) return true
|
||
|
||
// 只要有一个活跃 key 没有配额/为空/过期,就刷新一次(接口会批量刷新所有活跃 key)
|
||
if (!hasAntigravityQuotaDisplayData(key)) {
|
||
return true
|
||
}
|
||
const updatedAt = getAntigravityQuotaUpdatedAt(key)
|
||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function shouldAutoRefreshGeminiCliQuota(): boolean {
|
||
if (provider.value?.provider_type !== 'gemini_cli') return false
|
||
const now = Math.floor(Date.now() / 1000)
|
||
|
||
for (const { key } of allKeys.value) {
|
||
if (!key.is_active) continue
|
||
|
||
if (isTokenExpiringSoon(key, now)) return true
|
||
|
||
if (!hasGeminiCliQuotaDisplayData(key)) {
|
||
return true
|
||
}
|
||
|
||
const updatedAt = getGeminiCliQuotaUpdatedAt(key)
|
||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function shouldAutoRefreshKiroQuota(): boolean {
|
||
if (provider.value?.provider_type !== 'kiro') return false
|
||
const now = Math.floor(Date.now() / 1000)
|
||
|
||
for (const { key } of allKeys.value) {
|
||
if (!key.is_active) continue
|
||
|
||
if (isTokenExpiringSoon(key, now)) return true
|
||
|
||
// 只要有一个活跃 key 没有配额数据,就刷新一次
|
||
if (!hasKiroQuotaDisplayData(key)) {
|
||
return true
|
||
}
|
||
// 配额数据超过 5 分钟未更新,也触发刷新
|
||
const updatedAt = getKiroQuotaDisplay(key)?.updated_at
|
||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function shouldAutoRefreshGrokQuota(): boolean {
|
||
if (provider.value?.provider_type !== 'grok') return false
|
||
const now = Math.floor(Date.now() / 1000)
|
||
|
||
for (const { key } of allKeys.value) {
|
||
if (!key.is_active) continue
|
||
|
||
if (isTokenExpiringSoon(key, now)) return true
|
||
|
||
if (!hasGrokQuotaDisplayData(key)) {
|
||
return true
|
||
}
|
||
|
||
const updatedAt = getGrokQuotaDisplay(key)?.updated_at
|
||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function shouldAutoRefreshWindsurfQuota(): boolean {
|
||
if (provider.value?.provider_type !== 'windsurf') return false
|
||
const now = Math.floor(Date.now() / 1000)
|
||
|
||
for (const { key } of allKeys.value) {
|
||
if (!key.is_active) continue
|
||
|
||
if (isTokenExpiringSoon(key, now)) return true
|
||
|
||
if (!hasWindsurfQuotaDisplayData(key)) {
|
||
return true
|
||
}
|
||
|
||
const updatedAt = getWindsurfQuotaDisplay(key)?.updated_at
|
||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function shouldAutoRefreshChatGPTWebQuota(): boolean {
|
||
if (provider.value?.provider_type !== 'chatgpt_web') return false
|
||
const now = Math.floor(Date.now() / 1000)
|
||
|
||
for (const { key } of allKeys.value) {
|
||
if (!key.is_active) continue
|
||
|
||
if (isTokenExpiringSoon(key, now)) return true
|
||
|
||
if (!hasChatGPTWebQuotaDisplayData(key)) {
|
||
return true
|
||
}
|
||
|
||
const updatedAt = getChatGPTWebQuotaDisplay(key)?.updated_at
|
||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function defaultQuotaSnapshot(): QuotaStatusSnapshot {
|
||
return {
|
||
code: 'unknown',
|
||
exhausted: false,
|
||
usage_ratio: null,
|
||
updated_at: null,
|
||
reset_seconds: null,
|
||
plan_type: null,
|
||
}
|
||
}
|
||
|
||
function wrapQuotaMetadataForProvider(
|
||
providerType: string,
|
||
metadata: Record<string, unknown> | undefined,
|
||
): UpstreamMetadata | null {
|
||
if (!metadata) return null
|
||
if (providerType in metadata) {
|
||
return metadata as UpstreamMetadata
|
||
}
|
||
return { [providerType]: metadata } as UpstreamMetadata
|
||
}
|
||
|
||
// 将配额刷新结果就地应用到现有 key 上,避免重新拉列表导致分页重置
|
||
function applyQuotaResults(
|
||
results: { key_id: string; status: string; metadata?: Record<string, unknown>; quota_snapshot?: QuotaStatusSnapshot }[],
|
||
): number {
|
||
const providerType = provider.value?.provider_type
|
||
if (!providerType) return 0
|
||
|
||
let applied = 0
|
||
for (const r of results) {
|
||
const target = providerKeys.value.find(k => k.id === r.key_id)
|
||
if (!target) continue
|
||
|
||
let changed = false
|
||
const wrappedMetadata = wrapQuotaMetadataForProvider(providerType, r.metadata)
|
||
if (wrappedMetadata) {
|
||
target.upstream_metadata = { ...target.upstream_metadata, ...wrappedMetadata } as typeof target.upstream_metadata
|
||
changed = true
|
||
}
|
||
|
||
if (r.quota_snapshot) {
|
||
target.status_snapshot = {
|
||
oauth: target.status_snapshot?.oauth ?? {
|
||
code: 'none',
|
||
label: null,
|
||
reason: null,
|
||
expires_at: null,
|
||
invalid_at: null,
|
||
source: null,
|
||
requires_reauth: false,
|
||
expiring_soon: false,
|
||
},
|
||
account: target.status_snapshot?.account ?? {
|
||
code: 'ok',
|
||
label: null,
|
||
reason: null,
|
||
blocked: false,
|
||
source: null,
|
||
recoverable: false,
|
||
},
|
||
quota: {
|
||
...defaultQuotaSnapshot(),
|
||
...(target.status_snapshot?.quota ?? {}),
|
||
...r.quota_snapshot,
|
||
},
|
||
}
|
||
changed = true
|
||
}
|
||
|
||
if (changed) {
|
||
applied += 1
|
||
}
|
||
}
|
||
return applied
|
||
}
|
||
|
||
// 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
|
||
async function autoRefreshQuotaInBackground(): Promise<boolean> {
|
||
const providerId = props.providerId
|
||
if (!providerId) return false
|
||
if (refreshingQuota.value) return false
|
||
|
||
const providerType = provider.value?.provider_type
|
||
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return false
|
||
|
||
// 检查是否需要刷新
|
||
let shouldRefresh = false
|
||
if (providerType === 'codex') {
|
||
shouldRefresh = shouldAutoRefreshCodexQuota()
|
||
} else if (providerType === 'gemini_cli') {
|
||
shouldRefresh = shouldAutoRefreshGeminiCliQuota()
|
||
} else if (providerType === 'antigravity') {
|
||
shouldRefresh = shouldAutoRefreshAntigravityQuota()
|
||
} else if (providerType === 'kiro') {
|
||
shouldRefresh = shouldAutoRefreshKiroQuota()
|
||
} else if (providerType === 'grok') {
|
||
shouldRefresh = shouldAutoRefreshGrokQuota()
|
||
} else if (providerType === 'windsurf') {
|
||
shouldRefresh = shouldAutoRefreshWindsurfQuota()
|
||
} else if (providerType === 'chatgpt_web') {
|
||
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
|
||
}
|
||
if (!shouldRefresh) return false
|
||
|
||
let hadCachedQuota = false
|
||
if (providerType === 'codex') {
|
||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaDisplayData(key))
|
||
} else if (providerType === 'gemini_cli') {
|
||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGeminiCliQuotaDisplayData(key))
|
||
} else if (providerType === 'antigravity') {
|
||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasAntigravityQuotaDisplayData(key))
|
||
} else if (providerType === 'kiro') {
|
||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
|
||
} else if (providerType === 'grok') {
|
||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGrokQuotaDisplayData(key))
|
||
} else if (providerType === 'windsurf') {
|
||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasWindsurfQuotaDisplayData(key))
|
||
} else if (providerType === 'chatgpt_web') {
|
||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasChatGPTWebQuotaDisplayData(key))
|
||
}
|
||
|
||
refreshingQuota.value = true
|
||
try {
|
||
const result = await refreshProviderQuota(providerId)
|
||
const applied = applyQuotaResults(result.results)
|
||
if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') {
|
||
showError(legacyT('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)'), legacyT('提示'))
|
||
}
|
||
return applied > 0
|
||
} catch (err: unknown) {
|
||
if (!hadCachedQuota && providerType === 'antigravity') {
|
||
showError(localizedApiError(err, '后台刷新配额失败'), legacyT('错误'))
|
||
}
|
||
return false
|
||
} finally {
|
||
refreshingQuota.value = false
|
||
}
|
||
}
|
||
|
||
async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
|
||
antigravityQuotaDialogKey.value = key
|
||
antigravityQuotaDialogOpen.value = true
|
||
|
||
// 没有配额数据时主动获取
|
||
if (!hasAntigravityQuotaDisplayData(key)) {
|
||
if (refreshingQuota.value) return
|
||
refreshingQuota.value = true
|
||
try {
|
||
const result = await refreshProviderQuota(props.providerId)
|
||
applyQuotaResults(result.results)
|
||
// 更新弹窗引用的 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 Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||
emit('refresh')
|
||
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
|
||
void autoRefreshQuotaInBackground().then((changed) => {
|
||
if (changed) emit('refresh')
|
||
})
|
||
}
|
||
|
||
// 切换密钥启用状态
|
||
async function toggleKeyActive(key: EndpointAPIKey) {
|
||
if (togglingKeyId.value) return
|
||
|
||
togglingKeyId.value = key.id
|
||
try {
|
||
const newStatus = !key.is_active
|
||
const updated = await updateProviderKey(key.id, { is_active: newStatus })
|
||
Object.assign(key, updated)
|
||
key.is_active = newStatus
|
||
await Promise.all([loadProvider(), loadEndpoints()])
|
||
showSuccess(legacyT(newStatus ? '密钥已启用' : '密钥已停用'))
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '操作失败'), legacyT('错误'))
|
||
} finally {
|
||
togglingKeyId.value = null
|
||
}
|
||
}
|
||
|
||
// ===== Key 级别代理配置 =====
|
||
|
||
/** 获取 Key 当前代理节点的名称(用于显示) */
|
||
function getKeyProxyNodeName(key: EndpointAPIKey): string | null {
|
||
if (!key.proxy?.node_id) return null
|
||
const node = proxyNodesStore.nodes.find(n => n.id === key.proxy?.node_id)
|
||
return node ? node.name : `${key.proxy.node_id.slice(0, 8) }...`
|
||
}
|
||
|
||
/** 切换代理 Popover 的打开/关闭状态 */
|
||
function handleProxyPopoverToggle(keyId: string, open: boolean) {
|
||
proxyPopoverOpenKeyId.value = open ? keyId : null
|
||
if (open) {
|
||
proxyNodesStore.ensureLoaded()
|
||
}
|
||
}
|
||
|
||
/** 设置 Key 的代理节点 */
|
||
async function setKeyProxy(key: EndpointAPIKey, nodeId: string) {
|
||
savingProxyKeyId.value = key.id
|
||
try {
|
||
await updateProviderKey(key.id, {
|
||
proxy: { node_id: nodeId, enabled: true },
|
||
})
|
||
key.proxy = { node_id: nodeId, enabled: true }
|
||
proxyPopoverOpenKeyId.value = null
|
||
showSuccess(legacyT('代理节点已设置'))
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '设置代理失败'), legacyT('错误'))
|
||
} finally {
|
||
savingProxyKeyId.value = null
|
||
}
|
||
}
|
||
|
||
/** 清除 Key 的代理节点(回退到 Provider 级别代理) */
|
||
async function clearKeyProxy(key: EndpointAPIKey) {
|
||
savingProxyKeyId.value = key.id
|
||
try {
|
||
await updateProviderKey(key.id, { proxy: null })
|
||
key.proxy = null
|
||
proxyPopoverOpenKeyId.value = null
|
||
showSuccess(legacyT('已清除账号代理,将使用提供商级别代理'))
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '清除代理失败'), legacyT('错误'))
|
||
} finally {
|
||
savingProxyKeyId.value = null
|
||
}
|
||
}
|
||
|
||
// ===== 模型事件处理 =====
|
||
// 处理编辑模型
|
||
function handleEditModel(model: Model) {
|
||
editingModel.value = model
|
||
modelFormDialogOpen.value = true
|
||
}
|
||
|
||
// 处理打开批量关联对话框
|
||
function handleBatchAssign() {
|
||
batchAssignDialogOpen.value = true
|
||
}
|
||
|
||
function handleBatchAssignDialogOpenUpdate(value: boolean) {
|
||
batchAssignDialogOpen.value = value
|
||
}
|
||
|
||
// 处理批量关联完成
|
||
async function handleBatchAssignChanged() {
|
||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||
emit('refresh')
|
||
}
|
||
|
||
// 处理模型映射变更
|
||
async function handleModelMappingChanged() {
|
||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||
emit('refresh')
|
||
}
|
||
|
||
// 处理模型保存完成
|
||
async function handleModelSaved() {
|
||
editingModel.value = null
|
||
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||
emit('refresh')
|
||
}
|
||
|
||
// ===== 点击编辑优先级 =====
|
||
function startEditPriority(key: EndpointAPIKey) {
|
||
editingPriorityKey.value = key.id
|
||
editingPriorityValue.value = key.internal_priority ?? 0
|
||
prioritySaving.value = false
|
||
nextTick(() => {
|
||
// v-for 中的 ref 是数组,取第一个元素
|
||
const input = Array.isArray(priorityInputRef.value) ? priorityInputRef.value[0] : priorityInputRef.value
|
||
input?.focus()
|
||
input?.select()
|
||
})
|
||
}
|
||
|
||
function cancelEditPriority() {
|
||
editingPriorityKey.value = null
|
||
prioritySaving.value = false
|
||
}
|
||
|
||
function handlePriorityKeydown(e: KeyboardEvent, key: EndpointAPIKey) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (!prioritySaving.value) {
|
||
prioritySaving.value = true
|
||
savePriority(key)
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
cancelEditPriority()
|
||
}
|
||
}
|
||
|
||
function handlePriorityBlur(key: EndpointAPIKey) {
|
||
// 如果已经在保存中(Enter触发),不重复保存
|
||
if (prioritySaving.value) return
|
||
savePriority(key)
|
||
}
|
||
|
||
async function savePriority(key: EndpointAPIKey) {
|
||
const keyId = editingPriorityKey.value
|
||
const newPriority = parseInt(String(editingPriorityValue.value), 10) || 0
|
||
|
||
if (!keyId || newPriority < 0) {
|
||
cancelEditPriority()
|
||
return
|
||
}
|
||
|
||
// 如果优先级没有变化,直接取消编辑
|
||
if (key.internal_priority === newPriority) {
|
||
cancelEditPriority()
|
||
return
|
||
}
|
||
|
||
cancelEditPriority()
|
||
|
||
try {
|
||
await updateProviderKey(keyId, { internal_priority: newPriority })
|
||
showSuccess(legacyT('优先级已更新'))
|
||
// 更新本地数据 - 更新 providerKeys 中的数据
|
||
const keyToUpdate = providerKeys.value.find(k => k.id === keyId)
|
||
if (keyToUpdate) {
|
||
keyToUpdate.internal_priority = newPriority
|
||
}
|
||
// 重新排序
|
||
providerKeys.value.sort((a, b) => (a.internal_priority ?? 0) - (b.internal_priority ?? 0))
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '更新优先级失败'), legacyT('错误'))
|
||
}
|
||
}
|
||
|
||
// ===== 点击编辑倍率 =====
|
||
function startEditMultiplier(key: EndpointAPIKey, format: string) {
|
||
editingMultiplierKey.value = key.id
|
||
editingMultiplierFormat.value = format
|
||
editingMultiplierValue.value = getKeyRateMultiplier(key, format)
|
||
multiplierSaving.value = false
|
||
nextTick(() => {
|
||
const input = Array.isArray(multiplierInputRef.value) ? multiplierInputRef.value[0] : multiplierInputRef.value
|
||
input?.focus()
|
||
input?.select()
|
||
})
|
||
}
|
||
|
||
function cancelEditMultiplier() {
|
||
editingMultiplierKey.value = null
|
||
editingMultiplierFormat.value = null
|
||
}
|
||
|
||
function handleMultiplierKeydown(e: KeyboardEvent, key: EndpointAPIKey, format: string) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
saveMultiplier(key, format)
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
multiplierSaving.value = true // 阻止 blur 触发保存
|
||
cancelEditMultiplier()
|
||
}
|
||
}
|
||
|
||
function handleMultiplierBlur(key: EndpointAPIKey, format: string) {
|
||
if (multiplierSaving.value) return
|
||
saveMultiplier(key, format)
|
||
}
|
||
|
||
async function saveMultiplier(key: EndpointAPIKey, format: string) {
|
||
// 防止重复调用(Enter 触发后阻止 blur 再次进入)
|
||
if (multiplierSaving.value) return
|
||
multiplierSaving.value = true
|
||
|
||
const keyId = editingMultiplierKey.value
|
||
const newMultiplier = parseFloat(String(editingMultiplierValue.value))
|
||
|
||
// 验证输入有效性
|
||
if (!keyId || isNaN(newMultiplier)) {
|
||
showError(legacyT('请输入有效的倍率值'))
|
||
cancelEditMultiplier()
|
||
multiplierSaving.value = false
|
||
return
|
||
}
|
||
|
||
// 验证合理范围
|
||
if (newMultiplier <= 0 || newMultiplier > 100) {
|
||
showError(legacyT('倍率必须在 0.01 到 100 之间'))
|
||
cancelEditMultiplier()
|
||
multiplierSaving.value = false
|
||
return
|
||
}
|
||
|
||
// 如果倍率没有变化,直接取消编辑(使用精度容差比较浮点数)
|
||
const currentMultiplier = getKeyRateMultiplier(key, format)
|
||
if (Math.abs(currentMultiplier - newMultiplier) < 0.0001) {
|
||
cancelEditMultiplier()
|
||
multiplierSaving.value = false
|
||
return
|
||
}
|
||
|
||
cancelEditMultiplier()
|
||
|
||
try {
|
||
// 构建 rate_multipliers 对象
|
||
const rateMultipliers = { ...(key.rate_multipliers || {}) }
|
||
rateMultipliers[format] = newMultiplier
|
||
|
||
await updateProviderKey(keyId, { rate_multipliers: rateMultipliers })
|
||
showSuccess(legacyT('倍率已更新'))
|
||
|
||
// 更新本地数据
|
||
const keyToUpdate = providerKeys.value.find(k => k.id === keyId)
|
||
if (keyToUpdate) {
|
||
keyToUpdate.rate_multipliers = rateMultipliers
|
||
}
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '更新倍率失败'), legacyT('错误'))
|
||
} finally {
|
||
multiplierSaving.value = false
|
||
}
|
||
}
|
||
|
||
// ===== 密钥列表拖拽排序 =====
|
||
function handleKeyDragStart(event: DragEvent, index: number) {
|
||
keyDragState.value.isDragging = true
|
||
keyDragState.value.draggedIndex = index
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.effectAllowed = 'move'
|
||
event.dataTransfer.setData('text/plain', String(index))
|
||
}
|
||
}
|
||
|
||
function handleKeyDragEnd() {
|
||
keyDragState.value.isDragging = false
|
||
keyDragState.value.draggedIndex = null
|
||
keyDragState.value.targetIndex = null
|
||
}
|
||
|
||
function handleKeyDragOver(event: DragEvent, index: number) {
|
||
event.preventDefault()
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.dropEffect = 'move'
|
||
}
|
||
if (keyDragState.value.draggedIndex !== index) {
|
||
keyDragState.value.targetIndex = index
|
||
}
|
||
}
|
||
|
||
function handleKeyDragLeave() {
|
||
keyDragState.value.targetIndex = null
|
||
}
|
||
|
||
async function handleKeyDrop(event: DragEvent, targetIndex: number) {
|
||
event.preventDefault()
|
||
|
||
const draggedIndex = keyDragState.value.draggedIndex
|
||
if (draggedIndex === null || draggedIndex === targetIndex) {
|
||
handleKeyDragEnd()
|
||
return
|
||
}
|
||
|
||
const keys = allKeys.value.map(item => item.key)
|
||
if (draggedIndex < 0 || draggedIndex >= keys.length || targetIndex < 0 || targetIndex >= keys.length) {
|
||
handleKeyDragEnd()
|
||
return
|
||
}
|
||
|
||
const draggedKey = keys[draggedIndex]
|
||
const targetKey = keys[targetIndex]
|
||
const draggedPriority = draggedKey.internal_priority ?? 0
|
||
const targetPriority = targetKey.internal_priority ?? 0
|
||
|
||
// 如果是同组内拖拽(同优先级),忽略操作
|
||
if (draggedPriority === targetPriority) {
|
||
handleKeyDragEnd()
|
||
return
|
||
}
|
||
|
||
handleKeyDragEnd()
|
||
|
||
try {
|
||
// 记录每个 key 的原始优先级
|
||
const originalPriorityMap = new Map<string, number>()
|
||
keys.forEach(k => {
|
||
originalPriorityMap.set(k.id, k.internal_priority ?? 0)
|
||
})
|
||
|
||
// 重排数组:将被拖动项移到目标位置
|
||
const items = [...keys]
|
||
items.splice(draggedIndex, 1)
|
||
items.splice(targetIndex, 0, draggedKey)
|
||
|
||
// 按新顺序分配优先级:被拖动项单独成组,其他同组项保持在一起
|
||
const groupNewPriority = new Map<number, number>()
|
||
let currentPriority = 1
|
||
const newPriorityMap = new Map<string, number>()
|
||
|
||
items.forEach(key => {
|
||
const originalPriority = originalPriorityMap.get(key.id) ?? 0
|
||
|
||
if (key === draggedKey) {
|
||
// 被拖动的项单独成组
|
||
newPriorityMap.set(key.id, currentPriority)
|
||
currentPriority++
|
||
} else {
|
||
if (groupNewPriority.has(originalPriority)) {
|
||
// 同组的其他项使用相同的新优先级
|
||
newPriorityMap.set(key.id, groupNewPriority.get(originalPriority) ?? currentPriority)
|
||
} else {
|
||
// 新组,分配新优先级
|
||
groupNewPriority.set(originalPriority, currentPriority)
|
||
newPriorityMap.set(key.id, currentPriority)
|
||
currentPriority++
|
||
}
|
||
}
|
||
})
|
||
|
||
// 更新所有优先级发生变化的 key
|
||
const updatePromises = keys.map(key => {
|
||
const oldPriority = key.internal_priority ?? 0
|
||
const newPriority = newPriorityMap.get(key.id)
|
||
if (newPriority !== undefined && oldPriority !== newPriority) {
|
||
return updateProviderKey(key.id, { internal_priority: newPriority })
|
||
}
|
||
return Promise.resolve()
|
||
})
|
||
|
||
await Promise.all(updatePromises)
|
||
showSuccess(legacyT('优先级已更新'))
|
||
await loadEndpoints()
|
||
emit('refresh')
|
||
} catch (err: unknown) {
|
||
showError(localizedApiError(err, '更新优先级失败'), legacyT('错误'))
|
||
await loadEndpoints()
|
||
}
|
||
}
|
||
|
||
// 获取密钥的 API 格式列表(按指定顺序排序)
|
||
function getKeyApiFormats(key: EndpointAPIKey, endpoint?: ProviderEndpointWithKeys): string[] {
|
||
const providerType = provider.value?.provider_type
|
||
let formats: string[] = []
|
||
|
||
if (
|
||
providerType
|
||
&& isOAuthAccountProviderType(providerType)
|
||
&& isOAuthManagedCredential(key)
|
||
) {
|
||
formats = [...availableKeyApiFormats.value]
|
||
} else if (key.api_formats && key.api_formats.length > 0) {
|
||
formats = [...key.api_formats]
|
||
} else if (endpoint) {
|
||
formats = [endpoint.api_format]
|
||
}
|
||
// 使用统一的排序函数
|
||
return sortApiFormats(formats)
|
||
}
|
||
|
||
// 获取密钥在指定 API 格式下的成本倍率
|
||
function getKeyRateMultiplier(key: EndpointAPIKey, format: string): number {
|
||
if (key.rate_multipliers && key.rate_multipliers[format] !== undefined) {
|
||
return key.rate_multipliers[format]
|
||
}
|
||
return 1.0
|
||
}
|
||
|
||
// OAuth 订阅类型格式化
|
||
function formatOAuthPlanType(planType: string): string {
|
||
const labels: Record<string, string> = {
|
||
plus: 'Plus',
|
||
pro: 'Pro',
|
||
free: 'Free',
|
||
paid: 'Paid',
|
||
team: 'Team',
|
||
enterprise: 'Enterprise',
|
||
ultra: 'Ultra',
|
||
basic: 'Basic',
|
||
super: 'Super',
|
||
heavy: 'Heavy',
|
||
}
|
||
return labels[planType.toLowerCase()] || planType
|
||
}
|
||
|
||
// Codex 剩余额度样式(基于已用百分比计算剩余)
|
||
function getQuotaRemainingClass(usedPercent: number): string {
|
||
const remaining = 100 - usedPercent
|
||
if (remaining <= 10) return 'text-red-600 dark:text-red-400'
|
||
if (remaining <= 30) return 'text-yellow-600 dark:text-yellow-400'
|
||
return 'text-green-600 dark:text-green-400'
|
||
}
|
||
|
||
// Codex 剩余额度进度条颜色
|
||
function getQuotaRemainingBarColor(usedPercent: number): string {
|
||
const remaining = 100 - usedPercent
|
||
if (remaining <= 10) return 'bg-red-500 dark:bg-red-400'
|
||
if (remaining <= 30) return 'bg-yellow-500 dark:bg-yellow-400'
|
||
return 'bg-green-500 dark:bg-green-400'
|
||
}
|
||
|
||
// 判断是否为 Codex Team/Plus/Enterprise 账号(有 5H 限额,显示 3 列)
|
||
function isCodexTeamPlan(key: EndpointAPIKey): boolean {
|
||
const planType = key.oauth_plan_type?.toLowerCase() || getCodexQuotaDisplay(key)?.plan_type?.toLowerCase()
|
||
// Free 账号返回 false(2 列),其他所有账号返回 true(3 列)
|
||
return planType !== undefined && planType !== 'free'
|
||
}
|
||
|
||
interface AntigravityQuotaItem {
|
||
model: string
|
||
label: string
|
||
usedPercent: number
|
||
remainingPercent: number
|
||
resetSeconds: number | null
|
||
}
|
||
|
||
interface GeminiCliQuotaItem {
|
||
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 hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||
if (Array.isArray(quota?.windows) && quota.windows.length > 0) {
|
||
return true
|
||
}
|
||
return hasAntigravityQuotaData(key.upstream_metadata)
|
||
}
|
||
|
||
function getGeminiCliQuotaUpdatedAt(key: EndpointAPIKey): number | undefined {
|
||
const quota = getQuotaSnapshotForProvider(key, 'gemini_cli')
|
||
const quotaUpdatedAt = getQuotaSnapshotUpdatedAt(quota)
|
||
if (typeof quotaUpdatedAt === 'number') return quotaUpdatedAt
|
||
const updatedAt = Number(key.upstream_metadata?.gemini_cli?.updated_at ?? NaN)
|
||
return Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : undefined
|
||
}
|
||
|
||
function hasGeminiCliQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||
if (getGeminiCliAccountCreditsText(key, 'gemini_cli')) return true
|
||
return getGeminiCliQuotaItems(key).length > 0
|
||
}
|
||
|
||
function getGeminiCliQuotaItems(key: EndpointAPIKey): GeminiCliQuotaItem[] {
|
||
const quota = getQuotaSnapshotForProvider(key, 'gemini_cli')
|
||
const windows = getQuotaWindowByScope(quota, 'model')
|
||
if (!quota || windows.length === 0) return []
|
||
|
||
const items = windows
|
||
.map((window) => {
|
||
const model = String(window.model || window.label || window.code || '').trim()
|
||
if (!model) return null
|
||
|
||
const usedPercent = getQuotaWindowUsedPercent(window)
|
||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||
if (usedPercent === undefined && remainingPercent === undefined) {
|
||
return null
|
||
}
|
||
|
||
const normalizedUsedPercent =
|
||
usedPercent !== undefined
|
||
? usedPercent
|
||
: Math.max(100 - (remainingPercent ?? 0), 0)
|
||
const normalizedRemainingPercent =
|
||
remainingPercent !== undefined
|
||
? remainingPercent
|
||
: Math.max(100 - normalizedUsedPercent, 0)
|
||
|
||
return {
|
||
model,
|
||
label: String(window.label || window.model || model),
|
||
usedPercent: normalizedUsedPercent,
|
||
remainingPercent: normalizedRemainingPercent,
|
||
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
|
||
} satisfies GeminiCliQuotaItem
|
||
})
|
||
.filter((item): item is GeminiCliQuotaItem => item !== null)
|
||
|
||
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
|
||
return items
|
||
}
|
||
|
||
function formatUpdatedAt(updatedAt: number): string {
|
||
if (!updatedAt || typeof updatedAt !== 'number') return ''
|
||
const now = Math.floor(Date.now() / 1000)
|
||
const diff = now - updatedAt
|
||
if (diff <= 60) return legacyT('刚刚更新')
|
||
const minutes = Math.floor(diff / 60)
|
||
if (minutes < 60) return locale.value === 'en-US' ? `${minutes}m ago` : `${minutes}分钟前更新`
|
||
const hours = Math.floor(minutes / 60)
|
||
if (hours < 24) return locale.value === 'en-US' ? `${hours}h ago` : `${hours}小时前更新`
|
||
const days = Math.floor(hours / 24)
|
||
return locale.value === 'en-US' ? `${days}d ago` : `${days}天前更新`
|
||
}
|
||
|
||
// 兼容旧函数名
|
||
const formatCodexUpdatedAt = formatUpdatedAt
|
||
const formatAntigravityUpdatedAt = formatUpdatedAt
|
||
|
||
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 secondsUntilUnixReset(resetAt: number | string | null | undefined): number | null {
|
||
const numericResetAt = Number(resetAt)
|
||
if (!Number.isFinite(numericResetAt) || numericResetAt <= 0) return null
|
||
const now = Math.floor(Date.now() / 1000)
|
||
return Math.max(Math.floor(numericResetAt - now), 0)
|
||
}
|
||
|
||
function coerceAntigravityPercent(value: number | string | null | undefined): number | undefined {
|
||
const numericValue = Number(value)
|
||
if (!Number.isFinite(numericValue)) return undefined
|
||
return Math.min(Math.max(numericValue, 0), 100)
|
||
}
|
||
|
||
function coerceAntigravityRemainingFraction(value: number | string | null | undefined): number | undefined {
|
||
const numericValue = Number(value)
|
||
if (!Number.isFinite(numericValue)) return undefined
|
||
return Math.min(Math.max(numericValue, 0), 1)
|
||
}
|
||
|
||
function getAntigravityQuotaItems(metadata: UpstreamMetadata | null | undefined): AntigravityQuotaItem[] {
|
||
const quotaByModel = metadata?.antigravity?.quota_by_model
|
||
if (!quotaByModel || typeof quotaByModel !== 'object') return []
|
||
|
||
const items: AntigravityQuotaItem[] = []
|
||
const opaqueDisplayIndex = { value: 1 }
|
||
for (const [model, rawInfo] of Object.entries(quotaByModel)) {
|
||
if (!model) continue
|
||
const info: Partial<AntigravityModelQuota> = rawInfo || {}
|
||
|
||
let usedPercent = coerceAntigravityPercent(info.used_percent)
|
||
if (usedPercent === undefined) {
|
||
const remainingFraction = coerceAntigravityRemainingFraction(info.remaining_fraction)
|
||
if (remainingFraction !== undefined) {
|
||
usedPercent = (1 - remainingFraction) * 100
|
||
} else {
|
||
continue
|
||
}
|
||
}
|
||
|
||
usedPercent = coerceAntigravityPercent(usedPercent) ?? 0
|
||
|
||
const remainingPercent = Math.max(100 - usedPercent, 0)
|
||
|
||
let resetSeconds = secondsUntilUnixReset(info.reset_at)
|
||
if (typeof info.reset_time === 'string' && info.reset_time.trim()) {
|
||
resetSeconds = secondsUntilReset(info.reset_time.trim()) ?? resetSeconds
|
||
}
|
||
|
||
items.push({
|
||
model,
|
||
label: resolveAntigravityQuotaLabel(model, info.display_name, opaqueDisplayIndex),
|
||
usedPercent,
|
||
remainingPercent,
|
||
resetSeconds,
|
||
})
|
||
}
|
||
|
||
items.sort(compareAntigravityQuotaItems)
|
||
return dedupeAntigravityQuotaItemsByLabel(items)
|
||
}
|
||
|
||
function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQuotaItem[] {
|
||
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
||
const windows = getQuotaWindowByScope(quota, 'model')
|
||
if (!quota || windows.length === 0) return []
|
||
const opaqueDisplayIndex = { value: 1 }
|
||
|
||
const items = windows
|
||
.map((window) => {
|
||
const model = String(window.model || window.label || window.code || '').trim()
|
||
if (!model) return null
|
||
|
||
const usedPercent = getQuotaWindowUsedPercent(window)
|
||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||
if (usedPercent === undefined && remainingPercent === undefined) {
|
||
return null
|
||
}
|
||
|
||
const normalizedUsedPercent =
|
||
usedPercent !== undefined
|
||
? usedPercent
|
||
: Math.max(100 - (remainingPercent ?? 0), 0)
|
||
const normalizedRemainingPercent =
|
||
remainingPercent !== undefined
|
||
? remainingPercent
|
||
: Math.max(100 - normalizedUsedPercent, 0)
|
||
|
||
return {
|
||
model,
|
||
label: resolveAntigravityQuotaLabel(
|
||
model,
|
||
window.label || window.model,
|
||
opaqueDisplayIndex,
|
||
),
|
||
usedPercent: normalizedUsedPercent,
|
||
remainingPercent: normalizedRemainingPercent,
|
||
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
|
||
} satisfies AntigravityQuotaItem
|
||
})
|
||
.filter((item): item is AntigravityQuotaItem => item !== null)
|
||
|
||
items.sort(compareAntigravityQuotaItems)
|
||
return dedupeAntigravityQuotaItemsByLabel(items)
|
||
}
|
||
|
||
const ANTIGRAVITY_QUOTA_PREVIEW_LIMIT = 6
|
||
|
||
function getAntigravityQuotaItemsForKey(key: EndpointAPIKey): AntigravityQuotaItem[] {
|
||
const snapshotItems = getAntigravityQuotaItemsFromSnapshot(key)
|
||
if (snapshotItems.length > 0) return snapshotItems
|
||
return getAntigravityQuotaItems(key.upstream_metadata)
|
||
}
|
||
|
||
function getAntigravityQuotaPreviewForKey(key: EndpointAPIKey): AntigravityQuotaItem[] {
|
||
return getAntigravityQuotaItemsForKey(key).slice(0, ANTIGRAVITY_QUOTA_PREVIEW_LIMIT)
|
||
}
|
||
|
||
function getAntigravityQuotaHiddenCountForKey(key: EndpointAPIKey): number {
|
||
return Math.max(getAntigravityQuotaItemsForKey(key).length - ANTIGRAVITY_QUOTA_PREVIEW_LIMIT, 0)
|
||
}
|
||
|
||
function getResetCountdownText(
|
||
resetAt: number | null | undefined,
|
||
resetSecs: number | null | undefined,
|
||
updatedAt: number | null | undefined,
|
||
usedPercent: number | null | undefined
|
||
): string {
|
||
const status = getCodexResetCountdown(
|
||
resetAt,
|
||
resetSecs,
|
||
updatedAt,
|
||
countdownTick.value,
|
||
toCodexRemainingPercent(usedPercent)
|
||
)
|
||
if (!status) return ''
|
||
return status.isExpired ? legacyT(status.text) : `${legacyT(status.text)} ${legacyT('后重置')}`
|
||
}
|
||
|
||
function getCodexQuotaResetText(
|
||
resetAt: number | null | undefined,
|
||
resetSecs: number | null | undefined,
|
||
updatedAt: number | null | undefined,
|
||
usedPercent: number | null | undefined
|
||
): string {
|
||
if (resetAt || resetSecs) {
|
||
return getResetCountdownText(resetAt, resetSecs, updatedAt, usedPercent)
|
||
}
|
||
return legacyT('已重置')
|
||
}
|
||
|
||
function getResetCountdownClass(
|
||
resetAt: number | null | undefined,
|
||
resetSecs: number | null | undefined,
|
||
updatedAt: number | null | undefined,
|
||
usedPercent: number | null | undefined
|
||
): string {
|
||
const status = getCodexResetCountdown(
|
||
resetAt,
|
||
resetSecs,
|
||
updatedAt,
|
||
countdownTick.value,
|
||
toCodexRemainingPercent(usedPercent)
|
||
)
|
||
if (!status || status.isExpired) return 'text-muted-foreground/70'
|
||
if (status.isCritical) return 'text-destructive font-medium animate-pulse'
|
||
if (status.isUrgent) return 'text-amber-500 dark:text-amber-400'
|
||
return 'text-muted-foreground/70'
|
||
}
|
||
|
||
function toCodexRemainingPercent(usedPercent: number | null | undefined): number | null {
|
||
const normalizedUsed = Number(usedPercent)
|
||
if (!Number.isFinite(normalizedUsed)) return null
|
||
const clampedUsed = Math.min(Math.max(normalizedUsed, 0), 100)
|
||
return Math.max(100 - clampedUsed, 0)
|
||
}
|
||
|
||
function shouldStartCodexResetCountdown(usedPercent: number | null | undefined): boolean {
|
||
const remainingPercent = toCodexRemainingPercent(usedPercent)
|
||
if (remainingPercent == null) return true
|
||
return remainingPercent < 100
|
||
}
|
||
|
||
// 格式化重置时间
|
||
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 locale.value === 'en-US' ? `${days}d ${hours}h` : `${days}天 ${hours}小时`
|
||
}
|
||
if (hours > 0) {
|
||
return locale.value === 'en-US' ? `${hours}h ${minutes}m` : `${hours}小时 ${minutes}分钟`
|
||
}
|
||
return locale.value === 'en-US' ? `${minutes}m` : `${minutes}分钟`
|
||
}
|
||
|
||
// OAuth 订阅类型样式
|
||
function getOAuthPlanTypeClass(planType: string): string {
|
||
const classes: Record<string, string> = {
|
||
plus: 'border-green-500/50 text-green-600 dark:text-green-400',
|
||
pro: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
|
||
free: 'border-primary/50 text-primary',
|
||
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',
|
||
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
|
||
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
||
basic: 'border-primary/50 text-primary',
|
||
super: 'border-green-500/50 text-green-600 dark:text-green-400',
|
||
heavy: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
||
}
|
||
return classes[planType.toLowerCase()] || ''
|
||
}
|
||
|
||
// OAuth 状态信息(包括失效和过期)
|
||
function getKeyOAuthExpires(key: EndpointAPIKey) {
|
||
return getOAuthStatusDisplayWithFallback(key, countdownTick.value)
|
||
}
|
||
|
||
function getOAuthRefreshButtonTitle(key: EndpointAPIKey): string {
|
||
return resolveOAuthRefreshButtonTitle(key, countdownTick.value)
|
||
}
|
||
|
||
// OAuth 状态的 title 提示
|
||
function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
||
const accountTitle = getAccountStatusTitle(key)
|
||
if (accountTitle && isAccountLevelBlock(key)) {
|
||
return accountTitle
|
||
}
|
||
return resolveOAuthStatusTitle(key, countdownTick.value)
|
||
}
|
||
|
||
// 健康度颜色
|
||
function getHealthScoreColor(score: number): string {
|
||
if (score >= 0.8) return 'text-green-600 dark:text-green-400'
|
||
if (score >= 0.5) return 'text-yellow-600 dark:text-yellow-400'
|
||
return 'text-red-600 dark:text-red-400'
|
||
}
|
||
|
||
function getHealthScoreBarColor(score: number): string {
|
||
if (score >= 0.8) return 'bg-green-500 dark:bg-green-400'
|
||
if (score >= 0.5) return 'bg-yellow-500 dark:bg-yellow-400'
|
||
return 'bg-red-500 dark:bg-red-400'
|
||
}
|
||
|
||
function isKeyRecoverable(key: EndpointAPIKey): boolean {
|
||
return Boolean(
|
||
key.circuit_breaker_open
|
||
|| (key.health_score !== undefined && key.health_score < 0.5)
|
||
)
|
||
}
|
||
|
||
function getOpenCircuitEntries(key: EndpointAPIKey): Array<[string, NonNullable<EndpointAPIKey['circuit_breaker_by_format']>[string]]> {
|
||
return Object.entries(key.circuit_breaker_by_format || {})
|
||
.filter(([, value]) => value?.open === true)
|
||
}
|
||
|
||
function getKeyCircuitProbeCountdown(key: EndpointAPIKey): string {
|
||
void countdownTick.value
|
||
const nextProbe = getOpenCircuitEntries(key)
|
||
.map(([, value]) => {
|
||
if (typeof value.next_probe_at_unix_secs === 'number' && Number.isFinite(value.next_probe_at_unix_secs)) {
|
||
return value.next_probe_at_unix_secs * 1000
|
||
}
|
||
if (value.next_probe_at) {
|
||
const ms = new Date(value.next_probe_at).getTime()
|
||
return Number.isFinite(ms) ? ms : null
|
||
}
|
||
return null
|
||
})
|
||
.filter((value): value is number => value !== null)
|
||
.sort((a, b) => a - b)[0]
|
||
if (!nextProbe) {
|
||
return ''
|
||
}
|
||
const diffMs = nextProbe - Date.now()
|
||
return diffMs > 0 ? ` ${formatCountdown(diffMs)}` : ` ${legacyT('探测中')}`
|
||
}
|
||
|
||
function getKeyCircuitBreakerTitle(key: EndpointAPIKey): string {
|
||
const entries = getOpenCircuitEntries(key)
|
||
if (entries.length === 0) return legacyT('熔断器已打开')
|
||
const parts = entries.map(([format, value]) => {
|
||
const label = formatApiFormatShort(format)
|
||
const reason = value.reason ? `${legacyT('原因')}: ${value.reason}` : `${legacyT('原因')}: ${legacyT('连续失败')}`
|
||
const interval = typeof value.probe_interval_minutes === 'number'
|
||
? `${legacyT('探测间隔')}: ${value.probe_interval_minutes} ${legacyT('分钟')}`
|
||
: ''
|
||
const countdown = getFormatProbeCountdown(key, format).trim()
|
||
return [label, reason, interval, countdown ? `${legacyT('状态')}: ${countdown}` : '']
|
||
.filter(Boolean)
|
||
.join(' / ')
|
||
})
|
||
parts.push(legacyT('点击恢复按钮可重置熔断器'))
|
||
return parts.join('\n')
|
||
}
|
||
|
||
function getRecoverKeyTitle(key: EndpointAPIKey): string {
|
||
if (key.circuit_breaker_open) {
|
||
return legacyT('重置熔断器并恢复健康状态')
|
||
}
|
||
return legacyT('刷新健康状态')
|
||
}
|
||
|
||
// 获取自动获取模型状态的 title 提示
|
||
function getAutoFetchStatusTitle(key: EndpointAPIKey): string {
|
||
const parts: string[] = [legacyT('自动获取模型已启用')]
|
||
|
||
if (key.last_models_fetch_at) {
|
||
const date = new Date(key.last_models_fetch_at)
|
||
parts.push(`${legacyT('上次同步')}: ${date.toLocaleString(locale.value)}`)
|
||
}
|
||
|
||
if (key.last_models_fetch_error) {
|
||
parts.push(`${legacyT('错误')}: ${key.last_models_fetch_error}`)
|
||
}
|
||
|
||
return parts.join('\n')
|
||
}
|
||
|
||
// 检查指定格式是否熔断
|
||
function isFormatCircuitOpen(key: EndpointAPIKey, format: string): boolean {
|
||
if (!key.circuit_breaker_by_format) return false
|
||
const formatData = key.circuit_breaker_by_format[format]
|
||
return formatData?.open === true
|
||
}
|
||
|
||
// 获取指定格式的探测倒计时(如果熔断,返回带空格前缀的倒计时文本)
|
||
function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
|
||
// 触发响应式更新
|
||
void countdownTick.value
|
||
|
||
if (!key.circuit_breaker_by_format) return ''
|
||
const formatData = key.circuit_breaker_by_format[format]
|
||
if (!formatData?.open) return ''
|
||
|
||
// 半开状态
|
||
if (formatData.half_open_until) {
|
||
const halfOpenUntil = new Date(formatData.half_open_until)
|
||
const now = new Date()
|
||
if (halfOpenUntil > now) {
|
||
return ` ${legacyT('探测中')}`
|
||
}
|
||
}
|
||
// 等待探测
|
||
if (formatData.next_probe_at_unix_secs || formatData.next_probe_at) {
|
||
const nextProbeMs = typeof formatData.next_probe_at_unix_secs === 'number'
|
||
? formatData.next_probe_at_unix_secs * 1000
|
||
: new Date(formatData.next_probe_at || '').getTime()
|
||
const diffMs = nextProbeMs - Date.now()
|
||
if (diffMs > 0) {
|
||
return ` ${formatCountdown(diffMs)}`
|
||
} else {
|
||
return ` ${legacyT('探测中')}`
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
// 加载系统级格式转换配置
|
||
async function loadSystemFormatConversionConfig() {
|
||
try {
|
||
const result = await adminApi.getSystemConfig('enable_format_conversion')
|
||
systemFormatConversionEnabled.value = result.value === true
|
||
} catch {
|
||
// 获取失败时默认为关闭
|
||
systemFormatConversionEnabled.value = false
|
||
}
|
||
}
|
||
|
||
// 加载 Provider 信息
|
||
async function loadProvider() {
|
||
if (!props.providerId) return
|
||
const requestId = ++providerLoadRequestId
|
||
const shouldShowSpinner = !provider.value || provider.value.id !== props.providerId
|
||
|
||
try {
|
||
if (shouldShowSpinner) {
|
||
loading.value = true
|
||
}
|
||
// 系统级格式转换配置只影响一个图标状态,不应阻塞详情抽屉首屏。
|
||
void loadSystemFormatConversionConfig()
|
||
const providerData = await getProvider(props.providerId)
|
||
if (requestId !== providerLoadRequestId) return
|
||
applyProviderSnapshot(providerData)
|
||
keyPageSize.value = getProviderKeysPageSize(providerData.provider_type)
|
||
|
||
if (!provider.value) {
|
||
throw new Error(legacyT('Provider 不存在'))
|
||
}
|
||
} catch (err: unknown) {
|
||
if (requestId !== providerLoadRequestId) return
|
||
showError(localizedApiError(err, '加载失败'), legacyT('错误'))
|
||
} finally {
|
||
if (requestId === providerLoadRequestId && shouldShowSpinner) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadProviderKeysPage(page = currentKeyPage.value) {
|
||
if (!props.providerId) return
|
||
const providerId = props.providerId
|
||
const requestId = ++keysLoadRequestId
|
||
loadingProviderKeys.value = true
|
||
|
||
try {
|
||
const result = await getProviderKeysPage(providerId, {
|
||
page,
|
||
page_size: keyPageSize.value,
|
||
})
|
||
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
|
||
|
||
const nextTotalPages = Math.max(1, Math.ceil(result.total / result.page_size))
|
||
if (result.keys.length === 0 && result.total > 0 && result.page > nextTotalPages) {
|
||
await loadProviderKeysPage(nextTotalPages)
|
||
return
|
||
}
|
||
|
||
providerKeys.value = result.keys
|
||
providerKeysTotal.value = result.total
|
||
currentKeyPage.value = Math.min(result.page, nextTotalPages)
|
||
keyPageSize.value = result.page_size
|
||
syncCurrentSelections(endpoints.value, result.keys)
|
||
} catch (err: unknown) {
|
||
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
|
||
providerKeys.value = []
|
||
providerKeysTotal.value = 0
|
||
syncCurrentSelections(endpoints.value, [])
|
||
showError(localizedApiError(err, '加载密钥失败'), legacyT('错误'))
|
||
} finally {
|
||
if (requestId === keysLoadRequestId) {
|
||
loadingProviderKeys.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 加载端点列表
|
||
async function loadEndpoints() {
|
||
if (!props.providerId) return
|
||
const providerId = props.providerId
|
||
const requestId = ++endpointsLoadRequestId
|
||
loadingProviderEndpoints.value = true
|
||
loadingProviderKeys.value = true
|
||
loadingProviderModels.value = true
|
||
|
||
const sortEndpoints = (items: ProviderEndpoint[]): ProviderEndpointWithKeys[] => {
|
||
return [...items].sort((a, b) => {
|
||
const aIdx = API_FORMAT_ORDER.indexOf(a.api_format)
|
||
const bIdx = API_FORMAT_ORDER.indexOf(b.api_format)
|
||
if (aIdx === -1 && bIdx === -1) return 0
|
||
if (aIdx === -1) return 1
|
||
if (bIdx === -1) return -1
|
||
return aIdx - bIdx
|
||
})
|
||
}
|
||
|
||
const endpointsPromise = getProviderEndpoints(providerId)
|
||
.then((endpointsList) => {
|
||
if (requestId !== endpointsLoadRequestId) return
|
||
const sortedEndpoints = sortEndpoints(endpointsList)
|
||
endpoints.value = sortedEndpoints
|
||
syncCurrentSelections(sortedEndpoints, providerKeys.value)
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (requestId !== endpointsLoadRequestId) return
|
||
endpoints.value = []
|
||
syncCurrentSelections([], providerKeys.value)
|
||
showError(localizedApiError(err, '加载端点失败'), legacyT('错误'))
|
||
})
|
||
.finally(() => {
|
||
if (requestId === endpointsLoadRequestId) {
|
||
loadingProviderEndpoints.value = false
|
||
}
|
||
})
|
||
|
||
const providerKeysPromise = loadProviderKeysPage(currentKeyPage.value)
|
||
|
||
const modelsPromise = getProviderModels(providerId)
|
||
.catch(() => [])
|
||
.then((modelsResult) => {
|
||
if (requestId !== endpointsLoadRequestId) return
|
||
providerModels.value = modelsResult
|
||
})
|
||
.finally(() => {
|
||
if (requestId === endpointsLoadRequestId) {
|
||
loadingProviderModels.value = false
|
||
}
|
||
})
|
||
|
||
await Promise.allSettled([endpointsPromise, providerKeysPromise, modelsPromise])
|
||
}
|
||
|
||
// 加载映射预览(独立于 loadEndpoints,不阻塞首屏渲染)
|
||
async function loadMappingPreview() {
|
||
if (!props.providerId) return
|
||
const requestId = ++mappingPreviewLoadRequestId
|
||
loadingProviderMappingPreview.value = true
|
||
try {
|
||
const preview = await getProviderMappingPreview(props.providerId)
|
||
if (requestId !== mappingPreviewLoadRequestId) return
|
||
providerMappingPreview.value = preview
|
||
} catch {
|
||
if (requestId !== mappingPreviewLoadRequestId) return
|
||
providerMappingPreview.value = null
|
||
} finally {
|
||
if (requestId === mappingPreviewLoadRequestId) {
|
||
loadingProviderMappingPreview.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 添加 ESC 键监听
|
||
useEscapeKey(() => {
|
||
if (props.open) {
|
||
handleClose()
|
||
}
|
||
}, {
|
||
disableOnInput: true,
|
||
once: false
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 抽屉过渡动画 */
|
||
.drawer-enter-active,
|
||
.drawer-leave-active {
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.drawer-enter-active .drawer-panel,
|
||
.drawer-leave-active .drawer-panel {
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.drawer-enter-from,
|
||
.drawer-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
.drawer-enter-from .drawer-panel {
|
||
transform: translateX(100%);
|
||
}
|
||
|
||
.drawer-leave-to .drawer-panel {
|
||
transform: translateX(100%);
|
||
}
|
||
|
||
.drawer-enter-to .drawer-panel,
|
||
.drawer-leave-from .drawer-panel {
|
||
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>
|