feat: 新增 Kiro 适配器、OAuth 改进与多项功能增强

- 新增 Kiro provider 适配器(EventStream 协议解析、令牌管理、用量追踪)
- 重构 OAuth 账户管理与统一配额机制
- 重构 Handler 基类(CLI adapter/handler、请求构建器、流处理器)
- 增强缓存监控后端 API 与前端可视化
- 改进 Gemini 格式标准化器与请求头处理
- Antigravity/Codex 适配器更新,移除旧 metadata_collector
- 新增数据库迁移:proxy provider API keys
- 前端 UI 多项优化

Co-Authored-By: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-02-09 01:05:48 +08:00
parent e324ffdcd6
commit b34dd12863
82 changed files with 6498 additions and 840 deletions

View File

@@ -169,6 +169,41 @@ export const {
listAffinities
} = cacheApi
// ==================== Redis 缓存分类管理 API ====================
export interface RedisCacheCategory {
key: string
name: string
pattern: string
description: string
count: number
}
export interface RedisCacheCategoriesResponse {
available: boolean
message?: string
categories: RedisCacheCategory[]
total_keys: number
}
export const redisCacheApi = {
/**
* 获取 Redis 缓存分类概览
*/
async getCategories(): Promise<RedisCacheCategoriesResponse> {
const response = await api.get('/api/admin/monitoring/cache/redis-keys')
return response.data.data
},
/**
* 清除指定分类的 Redis 缓存
*/
async clearCategory(category: string): Promise<{ status: string; message: string; category: string; deleted_count: number }> {
const response = await api.delete(`/api/admin/monitoring/cache/redis-keys/${category}`)
return response.data
}
}
// ==================== 缓存亲和性分析 API ====================
export interface TTLAnalysisUser {

View File

@@ -67,6 +67,14 @@ export async function revealEndpointKey(keyId: string): Promise<RevealKeyResult>
return response.data
}
/**
* 导出 OAuth Key 凭据(扁平 JSON用于跨实例迁移
*/
export async function exportKey(keyId: string): Promise<Record<string, any>> {
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/export`)
return response.data
}
/**
* 删除 Key
*/
@@ -140,6 +148,7 @@ export async function updateProviderKey(
auto_fetch_models: boolean // 是否启用自动获取模型
model_include_patterns: string[] // 模型包含规则
model_exclude_patterns: string[] // 模型排除规则
proxy: import('./types').ProxyConfig | null // Key 级别代理配置
}>
): Promise<EndpointAPIKey> {
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
@@ -176,3 +185,33 @@ export async function refreshProviderQuota(providerId: string): Promise<RefreshQ
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`)
return response.data
}
/**
* 批量导入 OAuth 凭据(通用)
* 支持的 Provider 类型Codex、Antigravity、GeminiCli、ClaudeCode、Kiro
*/
export interface BatchImportResultItem {
index: number
status: 'success' | 'error'
key_id?: string
key_name?: string
auth_method?: string
error?: string
}
export interface BatchImportResult {
total: number
success: number
failed: number
results: BatchImportResultItem[]
}
export async function batchImportOAuth(
providerId: string,
credentials: string
): Promise<BatchImportResult> {
const response = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, {
credentials,
})
return response.data
}

View File

@@ -24,7 +24,7 @@ export async function updateProvider(
providerId: string,
data: Partial<{
name: string
provider_type: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity'
provider_type: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
description: string | null
website: string
provider_priority: number
@@ -53,7 +53,7 @@ export async function updateProvider(
export async function createProvider(
data: {
name: string
provider_type?: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity'
provider_type?: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
description?: string
website?: string
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'

View File

@@ -278,19 +278,26 @@ export interface EndpointAPIKey {
oauth_invalid_reason?: string | null // OAuth Token 失效原因
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
upstream_metadata?: UpstreamMetadata | null
// Key 级别代理配置(覆盖 Provider 级别代理)
proxy?: ProxyConfig | null
}
// Codex 上游元数据类型
export interface CodexUpstreamMetadata {
updated_at?: number // 更新时间Unix 时间戳)
plan_type?: string // 套餐类型
primary_used_percent?: number // 限额窗口使用百分比
primary_reset_seconds?: number // 限额重置剩余秒数
primary_reset_at?: number // 限额重置时间Unix 时间戳)
primary_window_minutes?: number // 限额窗口大小(分钟)
secondary_used_percent?: number // 次级限额窗口使用百分比
secondary_reset_seconds?: number // 次级限额重置剩余秒数
secondary_reset_at?: number // 次级限额重置时间Unix 时间戳)
secondary_window_minutes?: number // 次级限额窗口大小(分钟)
primary_used_percent?: number // 限额窗口使用百分比
primary_reset_seconds?: number // 限额重置剩余秒数
primary_reset_at?: number // 限额重置时间Unix 时间戳)
primary_window_minutes?: number // 限额窗口大小(分钟)
secondary_used_percent?: number // 5H限额窗口使用百分比
secondary_reset_seconds?: number // 5H限额重置剩余秒数
secondary_reset_at?: number // 5H限额重置时间Unix 时间戳)
secondary_window_minutes?: number // 5H限额窗口大小(分钟)
code_review_used_percent?: number // 代码审查限额使用百分比
code_review_reset_seconds?: number // 代码审查限额重置剩余秒数
code_review_reset_at?: number // 代码审查限额重置时间Unix 时间戳)
code_review_window_minutes?: number // 代码审查限额窗口大小(分钟)
has_credits?: boolean // 是否有积分
credits_balance?: number // 积分余额
}
@@ -306,8 +313,22 @@ export interface AntigravityUpstreamMetadata {
quota_by_model?: Record<string, AntigravityModelQuota>
}
export interface UpstreamMetadata extends CodexUpstreamMetadata {
// Kiro 上游配额信息
export interface KiroUpstreamMetadata {
subscription_title?: string // 订阅类型 (如 "KIRO PRO+")
current_usage?: number // 当前使用量
usage_limit?: number // 使用限额
remaining?: number // 剩余额度
usage_percentage?: number // 使用百分比 (0-100)
next_reset_at?: number // 下次重置时间Unix 时间戳,毫秒)
email?: string // 用户邮箱
updated_at?: number // Unix 时间戳(秒)
}
export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
}
// 按格式的健康度数据
@@ -351,6 +372,8 @@ export interface EndpointAPIKeyUpdate {
// 模型过滤规则(仅当 auto_fetch_models=true 时生效)
model_include_patterns?: string[] // 模型包含规则(支持 * 和 ? 通配符)
model_exclude_patterns?: string[] // 模型排除规则(支持 * 和 ? 通配符)
// Key 级别代理配置(覆盖 Provider 级别代理null=清除
proxy?: ProxyConfig | null
}
export interface EndpointHealthDetail {
@@ -421,7 +444,7 @@ export interface PublicEndpointStatusMonitorResponse {
formats: PublicEndpointStatusMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity'
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
export interface ProviderWithEndpointsSummary {
id: string

View File

@@ -81,7 +81,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { EmptyState, LoadingState } from '@/components/common'
import { TableCard } from '@/components/ui'
import {

View File

@@ -70,7 +70,7 @@ interface Props {
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
loading: false
})

View File

@@ -146,7 +146,7 @@ const effectiveType = computed(() => {
// 过滤掉 type 和 class 属性,因为我们会单独处理
const filteredAttrs = computed(() => {
const { type, class: _, ...rest } = attrs
const { type: _type, class: _class, ...rest } = attrs
return rest
})

View File

@@ -375,7 +375,7 @@ const sendCodeButtonText = computed(() => {
})
// 用户名验证
const usernameRegex = /^[a-zA-Z0-9_.\-]+$/
const usernameRegex = /^[a-zA-Z0-9_.-]+$/
const usernameError = computed(() => {
const username = formData.value.username.trim()
if (!username) return ''

View File

@@ -948,7 +948,7 @@ function getEndpointRulesCount(endpoint: ProviderEndpoint): number {
}
// 检查端点是否有任何规则(包括正在编辑的空规则)
function hasAnyRules(endpoint: ProviderEndpoint): boolean {
function _hasAnyRules(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.rules.length > 0
@@ -1158,7 +1158,7 @@ function getEndpointBodyRulesCount(endpoint: ProviderEndpoint): number {
}
// 检查端点是否有任何请求体规则(包括正在编辑的空规则)
function hasAnyBodyRules(endpoint: ProviderEndpoint): boolean {
function _hasAnyBodyRules(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.bodyRules.length > 0
@@ -1172,7 +1172,7 @@ function getTotalRulesCount(endpoint: ProviderEndpoint): number {
}
// 格式化请求头规则的显示标签
function formatHeaderRuleLabel(rule: EditableRule): string {
function _formatHeaderRuleLabel(rule: EditableRule): string {
if (rule.action === 'set') {
if (!rule.key) return '(未设置)'
return `${rule.key}=${rule.value || '...'}`
@@ -1187,7 +1187,7 @@ function formatHeaderRuleLabel(rule: EditableRule): string {
}
// 格式化请求体规则的显示标签
function formatBodyRuleLabel(rule: EditableBodyRule): string {
function _formatBodyRuleLabel(rule: EditableBodyRule): string {
if (rule.action === 'set') {
if (!rule.path) return '(未设置)'
return `${rule.path}=${rule.value || '...'}`
@@ -1279,7 +1279,7 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
}
// 检查请求体规则是否有验证错误
function hasBodyValidationErrorsForEndpoint(endpointId: string): boolean {
function _hasBodyValidationErrorsForEndpoint(endpointId: string): boolean {
return !!getBodyValidationErrorForEndpoint(endpointId)
}

View File

@@ -372,7 +372,7 @@ import {
} from '@/api/endpoints'
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
import { API_FORMAT_SHORT, sortApiFormats, type UpstreamModel } from '@/api/endpoints/types'
import { API_FORMAT_SHORT, type UpstreamModel } from '@/api/endpoints/types'
interface AvailableModel {
name: string
@@ -505,7 +505,7 @@ function toggleAllUpstreamModels() {
}
// 处理上游模型点击(自动同步模式下禁用)
function handleUpstreamModelClick(model: UpstreamModelInfo) {
function handleUpstreamModelClick(model: UpstreamModel) {
if (!isAutoFetchMode.value) {
toggleModel(model.id)
}

View File

@@ -11,9 +11,13 @@
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
<button
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
:class="mode === 'oauth'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'"
:disabled="isKiroProvider"
:class="[
mode === 'oauth'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
isKiroProvider ? 'opacity-50 cursor-not-allowed' : ''
]"
@click="switchMode('oauth')"
>
获取授权
@@ -232,11 +236,13 @@ import {
startProviderLevelOAuth,
completeProviderLevelOAuth,
importProviderRefreshToken,
batchImportOAuth,
} from '@/api/endpoints'
const props = defineProps<{
open: boolean
providerId: string | null
providerType: string | null
}>()
const emit = defineEmits<{
@@ -287,6 +293,8 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
const isOpen = computed(() => props.open)
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
const oauthBusy = computed(() =>
oauth.value.starting || oauth.value.completing
)
@@ -310,7 +318,7 @@ function resetForm() {
importing.value = false
isDragging.value = false
showManualInput.value = false
mode.value = 'oauth'
mode.value = isKiroProvider.value ? 'import' : 'oauth'
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
@@ -328,6 +336,13 @@ function clearImport() {
function switchMode(newMode: DialogMode) {
if (mode.value === newMode) return
if (newMode === 'oauth' && isKiroProvider.value) {
showError('Kiro \u4e0d\u652f\u6301 OAuth \u6388\u6743\uff0c\u8bf7\u4f7f\u7528\u5bfc\u5165\u6388\u6743', '\u63d0\u793a')
mode.value = 'import'
return
}
mode.value = newMode
if (newMode === 'oauth' && !oauth.value.authorization_url && !oauth.value.starting) {
initOAuth()
@@ -353,6 +368,8 @@ function openAuthorizationUrl() {
async function initOAuth() {
if (!props.providerId) return
if (isKiroProvider.value) return
oauth.value.starting = true
try {
@@ -364,7 +381,7 @@ async function initOAuth() {
} catch (err: any) {
const errorMessage = parseApiError(err, '初始化授权失败')
showError(errorMessage, '错误')
handleClose()
mode.value = 'import'
} finally {
oauth.value.starting = false
}
@@ -388,31 +405,63 @@ async function handleCompleteOAuth() {
}
}
// 检测是否为批量导入格式
function isBatchImport(text: string): boolean {
const trimmed = text.trim()
// JSON 数组
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) && parsed.length > 1
} catch {
return false
}
}
// 单个 JSON 对象(可能是 pretty-printed 多行)不算批量导入
if (trimmed.startsWith('{')) {
try {
JSON.parse(trimmed)
return false // 可解析的单个 JSON 对象,走单条导入
} catch {
// 解析失败:可能是多个 JSON 对象JSON Lines 格式),继续检查
}
}
// 多行文本(纯 Token 一行一个)
const lines = trimmed.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
return lines.length > 1
}
function parseImportText(text: string): { refresh_token: string; name?: string } | null {
const trimmed = text.trim()
if (!trimmed) return null
// Kiro: keep full JSON so backend can extract auth_method/region/client_id, etc.
if (isKiroProvider.value) {
return { refresh_token: trimmed }
}
try {
const parsed = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
const refreshToken = parsed.refresh_token
const refreshToken = (parsed as any).refresh_token
if (typeof refreshToken === 'string' && refreshToken.trim()) {
return {
refresh_token: refreshToken.trim(),
name: parsed.name || parsed.oauth_email || undefined,
name: (parsed as any).name || (parsed as any).oauth_email || undefined,
}
}
return null
}
} catch {
// 不是 JSON
// Not JSON: treat as raw token.
}
if (trimmed) {
return { refresh_token: trimmed }
}
return null
return { refresh_token: trimmed }
}
function readFile(file: File) {
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
showError('仅支持 .json 文件', '格式错误')
if (!file.name.endsWith('.json') && !file.name.endsWith('.txt') && file.type !== 'application/json' && file.type !== 'text/plain') {
showError('仅支持 .json 或 .txt 文件', '格式错误')
return
}
importFileName.value = file.name
@@ -441,19 +490,43 @@ function handleFileDrop(event: DragEvent) {
async function handleImport() {
if (!canImport.value || !props.providerId) return
const inputText = importText.value || manualPasteText.value
const parsed = parseImportText(inputText)
if (!parsed) {
showError('无法解析输入内容,请检查格式', '格式错误')
const inputText = (importText.value || manualPasteText.value).trim()
if (!inputText) {
showError('请输入凭据数据', '格式错误')
return
}
importing.value = true
try {
await importProviderRefreshToken(props.providerId, parsed)
success('导入成功,账号已添加')
emit('saved')
handleClose()
// 检测是否为批量导入
if (isBatchImport(inputText)) {
// 批量导入
const result = await batchImportOAuth(props.providerId, inputText)
if (result.success > 0) {
if (result.failed > 0) {
success(`批量导入完成:成功 ${result.success} 个,失败 ${result.failed}`)
} else {
success(`批量导入成功:${result.success} 个账号已添加`)
}
emit('saved')
handleClose()
} else {
// 全部失败,显示第一个错误
const firstError = result.results.find(r => r.status === 'error')
showError(firstError?.error || '批量导入失败', '导入失败')
}
} else {
// 单条导入
const parsed = parseImportText(inputText)
if (!parsed) {
showError('无法解析输入内容,请检查格式', '格式错误')
return
}
await importProviderRefreshToken(props.providerId, parsed)
success('导入成功,账号已添加')
emit('saved')
handleClose()
}
} catch (err: any) {
const errorMessage = parseApiError(err, '导入失败')
showError(errorMessage, '错误')
@@ -464,7 +537,11 @@ async function handleImport() {
watch(() => props.open, (newOpen) => {
if (newOpen) {
initOAuth()
if (isKiroProvider.value) {
mode.value = 'import'
} else {
initOAuth()
}
} else {
resetForm()
}

View File

@@ -189,22 +189,6 @@
{{ provider.provider_type === 'custom' ? '密钥管理' : '账号管理' }}
</h3>
<div class="flex items-center gap-2">
<!-- 刷新限额按钮Codex会产生少量调用费用Antigravity 采用打开抽屉自动后台刷新 -->
<Button
v-if="provider.provider_type === 'codex' && allKeys.length > 0"
variant="outline"
size="sm"
class="h-8"
:disabled="refreshingQuota"
title="刷新所有账号的限额信息"
@click="handleRefreshQuota"
>
<RefreshCw
class="w-3.5 h-3.5 mr-1.5"
:class="{ 'animate-spin': refreshingQuota }"
/>
刷新限额
</Button>
<Button
v-if="endpoints.length > 0"
variant="outline"
@@ -249,8 +233,8 @@
</div>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
<!-- OAuth 订阅类型标签 -->
<span class="text-sm font-medium truncate">{{ getKeyDisplayName(key) }}</span>
<!-- OAuth 订阅类型标签 (Codex) -->
<Badge
v-if="key.oauth_plan_type"
variant="outline"
@@ -259,6 +243,15 @@
>
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<!-- Kiro 订阅类型标签 -->
<Badge
v-if="provider.provider_type === 'kiro' && key.upstream_metadata?.kiro?.subscription_title"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
:class="getOAuthPlanTypeClass(formatKiroSubscription(key.upstream_metadata?.kiro?.subscription_title))"
>
{{ formatKiroSubscription(key.upstream_metadata?.kiro?.subscription_title) }}
</Badge>
</div>
<div class="flex items-center gap-1">
<span class="text-[11px] font-mono text-muted-foreground">
@@ -418,6 +411,55 @@
>
<BarChart3 class="w-3.5 h-3.5" />
</Button>
<!-- 代理节点配置仅非 custom 类型显示 -->
<Popover
v-if="provider.provider_type !== 'custom'"
:open="proxyPopoverOpenKeyId === key.id"
@update:open="(v: boolean) => handleProxyPopoverToggle(key.id, v)"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:class="key.proxy?.node_id ? 'text-blue-500' : ''"
:disabled="savingProxyKeyId === key.id"
:title="key.proxy?.node_id ? `代理: ${getKeyProxyNodeName(key)}` : '设置代理节点'"
@click.stop
>
<Globe class="w-3.5 h-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
class="w-72 p-3"
side="bottom"
align="end"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">代理节点</span>
<Button
v-if="key.proxy?.node_id"
variant="ghost"
size="sm"
class="h-6 px-2 text-[10px] text-muted-foreground"
:disabled="savingProxyKeyId === key.id"
@click="clearKeyProxy(key)"
>
清除
</Button>
</div>
<ProxyNodeSelect
:model-value="key.proxy?.node_id || ''"
trigger-class="h-8"
@update:model-value="(v: string) => setKeyProxy(key, v)"
/>
<p class="text-[10px] text-muted-foreground">
{{ key.proxy?.node_id ? '当前使用独立代理' : '未设置,使用提供商级别代理' }}
</p>
</div>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="icon"
@@ -444,54 +486,94 @@
v-if="key.upstream_metadata && hasCodexQuotaData(key.upstream_metadata)"
class="mt-2 p-2 bg-muted/30 rounded-md"
>
<!-- 限额并排显示 -->
<div class="grid grid-cols-2 gap-3">
<!-- 周限额7天窗口 -->
<div v-if="key.upstream_metadata.primary_used_percent !== undefined">
<div class="flex items-center justify-between mb-1">
<span class="text-[10px] text-muted-foreground">账号配额</span>
<div class="flex items-center gap-1">
<RefreshCw
v-if="refreshingQuota"
class="w-3 h-3 text-muted-foreground/70 animate-spin"
/>
<span
v-if="key.upstream_metadata.codex?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatCodexUpdatedAt(key.upstream_metadata.codex.updated_at) }}
</span>
</div>
</div>
<!-- 限额并排显示Team/Plus/Enterprise 账号 3列, Free 账号 2列 -->
<div
class="grid gap-3"
:class="isCodexTeamPlan(key) ? 'grid-cols-3' : 'grid-cols-2'"
>
<!-- 周限额 -->
<div v-if="key.upstream_metadata.codex?.primary_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">周限额</span>
<span :class="getQuotaRemainingClass(key.upstream_metadata.primary_used_percent)">
{{ (100 - key.upstream_metadata.primary_used_percent).toFixed(1) }}%
<span :class="getQuotaRemainingClass(key.upstream_metadata.codex.primary_used_percent)">
{{ (100 - key.upstream_metadata.codex.primary_used_percent).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(key.upstream_metadata.primary_used_percent)"
:style="{ width: `${Math.max(100 - key.upstream_metadata.primary_used_percent, 0)}%` }"
:class="getQuotaRemainingBarColor(key.upstream_metadata.codex.primary_used_percent)"
:style="{ width: `${Math.max(100 - key.upstream_metadata.codex.primary_used_percent, 0)}%` }"
/>
</div>
<div
v-if="key.upstream_metadata.primary_reset_seconds"
v-if="key.upstream_metadata.codex.primary_reset_seconds"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
{{ formatResetTime(key.upstream_metadata.primary_reset_seconds) }}后重置
{{ formatResetTime(key.upstream_metadata.codex.primary_reset_seconds) }}后重置
</div>
</div>
<!-- 5小时限额 -->
<div v-if="key.upstream_metadata.secondary_used_percent !== undefined">
<!-- 5H限额仅 Team/Plus/Enterprise 显示) -->
<div v-if="isCodexTeamPlan(key) && key.upstream_metadata.codex?.secondary_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">5H限额</span>
<span :class="getQuotaRemainingClass(key.upstream_metadata.secondary_used_percent)">
{{ (100 - key.upstream_metadata.secondary_used_percent).toFixed(1) }}%
<span :class="getQuotaRemainingClass(key.upstream_metadata.codex.secondary_used_percent)">
{{ (100 - key.upstream_metadata.codex.secondary_used_percent).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(key.upstream_metadata.secondary_used_percent)"
:style="{ width: `${Math.max(100 - key.upstream_metadata.secondary_used_percent, 0)}%` }"
:class="getQuotaRemainingBarColor(key.upstream_metadata.codex.secondary_used_percent)"
:style="{ width: `${Math.max(100 - key.upstream_metadata.codex.secondary_used_percent, 0)}%` }"
/>
</div>
<div class="text-[9px] text-muted-foreground/70 mt-0.5">
<template v-if="key.upstream_metadata.secondary_reset_seconds">
{{ formatResetTime(key.upstream_metadata.secondary_reset_seconds) }}后重置
<template v-if="key.upstream_metadata.codex.secondary_reset_seconds">
{{ formatResetTime(key.upstream_metadata.codex.secondary_reset_seconds) }}后重置
</template>
<template v-else>
已重置
</template>
</div>
</div>
<!-- 代码审查限额 -->
<div v-if="key.upstream_metadata.codex?.code_review_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">审查限额</span>
<span :class="getQuotaRemainingClass(key.upstream_metadata.codex.code_review_used_percent)">
{{ (100 - key.upstream_metadata.codex.code_review_used_percent).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(key.upstream_metadata.codex.code_review_used_percent)"
:style="{ width: `${Math.max(100 - key.upstream_metadata.codex.code_review_used_percent, 0)}%` }"
/>
</div>
<div
v-if="key.upstream_metadata.codex.code_review_reset_seconds"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
{{ formatResetTime(key.upstream_metadata.codex.code_review_reset_seconds) }}后重置
</div>
</div>
</div>
</div>
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
@@ -551,6 +633,55 @@
</div>
</div>
</div>
<!-- Kiro 上游额度信息(仅当有元数据时显示) -->
<div
v-if="provider.provider_type === 'kiro' && key.upstream_metadata && hasKiroQuotaData(key.upstream_metadata)"
class="mt-2 p-2 bg-muted/30 rounded-md"
>
<div class="flex items-center justify-between mb-1">
<span class="text-[10px] text-muted-foreground">账号配额</span>
<div class="flex items-center gap-1">
<RefreshCw
v-if="refreshingQuota"
class="w-3 h-3 text-muted-foreground/70 animate-spin"
/>
<span
v-if="key.upstream_metadata.kiro?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatKiroUpdatedAt(key.upstream_metadata.kiro?.updated_at) }}
</span>
</div>
</div>
<!-- Kiro 额度显示:使用进度 -->
<div>
<!-- 使用额度进度条 -->
<div>
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">使用额度</span>
<span :class="getQuotaRemainingClass(key.upstream_metadata.kiro?.usage_percentage || 0)">
{{ (100 - (key.upstream_metadata.kiro?.usage_percentage || 0)).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(key.upstream_metadata.kiro?.usage_percentage || 0)"
:style="{ width: `${Math.max(100 - (key.upstream_metadata.kiro?.usage_percentage || 0), 0)}%` }"
/>
</div>
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
<span>
{{ formatKiroUsage(key.upstream_metadata.kiro?.current_usage) }} /
{{ formatKiroUsage(key.upstream_metadata.kiro?.usage_limit) }}
</span>
<span v-if="key.upstream_metadata.kiro?.next_reset_at">
{{ formatKiroResetTime(key.upstream_metadata.kiro?.next_reset_at) }}重置
</span>
</div>
</div>
</div>
</div>
<!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 -->
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
<!-- 优先级放最前面,支持点击编辑 -->
@@ -705,6 +836,7 @@
v-if="open && provider"
:open="oauthAccountDialogOpen"
:provider-id="provider.id"
:provider-type="provider.provider_type"
@close="oauthAccountDialogOpen = false"
@saved="handleKeyChanged"
/>
@@ -784,11 +916,13 @@ import {
ExternalLink,
BarChart3,
ShieldX,
Globe,
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Card from '@/components/ui/card.vue'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
@@ -807,12 +941,15 @@ import EndpointFormDialog from '@/features/providers/components/EndpointFormDial
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
import AlertDialog from '@/components/common/AlertDialog.vue'
import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQuotaDialog.vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import {
deleteEndpointKey,
recoverKeyHealth,
getProviderKeys,
updateProviderKey,
revealEndpointKey,
exportKey,
refreshProviderOAuth,
refreshProviderQuota,
clearOAuthInvalid,
@@ -907,6 +1044,11 @@ const refreshingQuota = ref(false)
const antigravityQuotaDialogOpen = ref(false)
const antigravityQuotaDialogKey = ref<EndpointAPIKey | null>(null)
// Key 级别代理配置状态
const proxyNodesStore = useProxyNodesStore()
const savingProxyKeyId = ref<string | null>(null)
const proxyPopoverOpenKeyId = ref<string | null>(null)
// 描述编辑状态
const editingDescription = ref(false)
const editingDescriptionValue = ref('')
@@ -965,7 +1107,7 @@ const allKeys = computed(() => {
// 合并监听 providerId 和 open避免同一 tick 内两个 watcher 都触发导致重复请求
watch(
[() => props.providerId, () => props.open],
async ([newId, newOpen], [oldId, oldOpen]) => {
async ([newId, newOpen], [_oldId, oldOpen]) => {
if (newOpen && newId) {
await Promise.all([
loadProvider(),
@@ -975,7 +1117,7 @@ watch(
if (newOpen && !oldOpen) {
startCountdownTimer()
}
void autoRefreshAntigravityQuotaInBackground()
void autoRefreshQuotaInBackground()
} else if (!newOpen && oldOpen) {
// 停止倒计时定时器
stopCountdownTimer()
@@ -1149,48 +1291,28 @@ async function copyFullKey(key: EndpointAPIKey) {
}
}
// 下载 Refresh Token 授权文件
// 下载 OAuth 凭据文件(后端统一导出,前端只负责下载)
async function downloadRefreshToken(key: EndpointAPIKey) {
try {
const result = await revealEndpointKey(key.id)
const refreshToken = result.refresh_token || ''
const accessToken = result.api_key || ''
if (!refreshToken) {
showError('该账号没有 Refresh Token无法导出', '错误')
return
}
// 缓存 access_token 用于显示
if (accessToken) {
revealedKeys.value.set(key.id, accessToken)
}
const data = {
auth_type: 'oauth',
access_token: accessToken,
refresh_token: refreshToken,
name: key.name || '',
oauth_email: key.oauth_email || '',
exported_at: new Date().toISOString(),
}
const 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
const providerType = provider.value?.provider_type || 'unknown'
const safeName = (key.name || key.oauth_email || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
a.download = `aether_${providerType}_${safeName}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err: any) {
showError(err.response?.data?.detail || '获取 Refresh Token 失败', '错误')
showError(err.response?.data?.detail || '导出失败', '错误')
}
}
function handleDeleteKey(key: EndpointAPIKey) {
keyToDelete.value = key
deleteKeyConfirmOpen.value = true
@@ -1244,7 +1366,7 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
await loadEndpoints()
// Antigravitytoken 刷新后可能完成了账号激活,触发配额获取
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
void autoRefreshAntigravityQuotaInBackground()
void autoRefreshQuotaInBackground()
} catch (err: any) {
showError(err.response?.data?.detail || 'Token 刷新失败', '错误')
} finally {
@@ -1288,72 +1410,99 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
}
}
// 刷新所有账号限额(Codex / Antigravity
async function handleRefreshQuota() {
if (refreshingQuota.value || !props.providerId) return
// Codex / Antigravity / Kiro打开抽屉后自动后台刷新配额缓存缺失/过期,或 Token 即将过期时触发
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
// 确认对话框
const message = provider.value?.provider_type === 'codex'
? '这将使用每个账号发送测试请求以获取最新限额信息,可能产生少量 API 调用费用。是否继续?'
: '这将向每个账号请求一次上游额度信息以更新限额显示。是否继续?'
const confirmed = await confirm({
title: '获取限额',
message,
confirmText: '继续',
variant: 'info'
})
if (!confirmed) return
refreshingQuota.value = true
try {
const result = await refreshProviderQuota(props.providerId)
if (result.success > 0) {
showSuccess(`成功刷新 ${result.success}/${result.total} 个账号的限额`)
// 重新加载数据以更新 UI
await loadEndpoints()
} else if (result.failed > 0) {
showError(`刷新失败: ${result.results.map(r => r.message).filter(Boolean).join(', ')}`, '错误')
} else {
showError('没有获取到限额信息', '警告')
}
} catch (err: any) {
showError(err.response?.data?.detail || '刷新限额失败', '错误')
} finally {
refreshingQuota.value = false
}
// 检查 Codex 是否有配额数据
function hasCodexQuotaData(meta: UpstreamMetadata | null | undefined): boolean {
if (!meta?.codex) return false
// Codex 配额数据存储在 codex 子对象中
return meta.codex.primary_used_percent !== undefined || meta.codex.secondary_used_percent !== undefined
}
// Antigravity打开抽屉后自动后台刷新配额缓存缺失/过期,或 Token 即将过期时触发)
const ANTIGRAVITY_AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
const ANTIGRAVITY_AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
// 检查 Kiro 是否有配额数据
function hasKiroQuotaData(meta: UpstreamMetadata | null | undefined): boolean {
if (!meta?.kiro) return false
return meta.kiro.usage_percentage !== undefined || meta.kiro.usage_limit !== undefined
}
function shouldAutoRefreshAntigravityQuota(): boolean {
if (provider.value?.provider_type !== 'antigravity') return false
const now = Math.floor(Date.now() / 1000)
// 格式化 Kiro 更新时间
const formatKiroUpdatedAt = formatUpdatedAt
// 格式化 Kiro 使用量(带单位)
function formatKiroUsage(value: number | undefined): string {
if (value === undefined || value === null) return '-'
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`
}
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}K`
}
return value.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 '已重置'
}
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 `${days}天${hours}小时后`
}
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
if (hours > 0) {
return `${hours}小时${minutes}分钟后`
}
return `${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
}
// 获取 Key 的显示名称Kiro 优先显示邮箱)
function getKeyDisplayName(key: EndpointAPIKey): string {
// Kiro 类型优先显示邮箱(从 upstream_metadata.kiro.email 获取)
if (provider.value?.provider_type === 'kiro') {
const kiroEmail = key.upstream_metadata?.kiro?.email
if (kiroEmail) {
return kiroEmail
}
}
return key.name || '未命名密钥'
}
function shouldAutoRefreshCodexQuota(): boolean {
if (provider.value?.provider_type !== 'codex') return false
let hasActiveKey = false
for (const { key } of allKeys.value) {
if (!key.is_active) continue
hasActiveKey = true
// Token 已过期 / 即将过期:即使配额缓存还新,也触发一次后台刷新,
// 这样不会出现“打开抽屉看到已过期但没有任何刷新动作”的体验。
if (key.oauth_invalid_at == null && typeof key.oauth_expires_at === 'number') {
if ((key.oauth_expires_at - now) <= ANTIGRAVITY_AUTO_TOKEN_REFRESH_SKEW_SECONDS) {
return true
}
}
const meta: UpstreamMetadata | null | undefined = key.upstream_metadata
const updatedAt = meta?.antigravity?.updated_at
const quotaByModel = meta?.antigravity?.quota_by_model
// 只要有一个活跃 key 没有配额/为空/过期,就刷新一次(接口会批量刷新所有活跃 key
if (!quotaByModel || typeof quotaByModel !== 'object' || Object.keys(quotaByModel).length === 0) {
return true
}
if (typeof updatedAt !== 'number' || (now - updatedAt) > ANTIGRAVITY_AUTO_QUOTA_REFRESH_STALE_SECONDS) {
// 只要有一个活跃 key 没有配额数据,就刷新一次
if (!hasCodexQuotaData(meta)) {
return true
}
}
@@ -1361,17 +1510,83 @@ function shouldAutoRefreshAntigravityQuota(): boolean {
return false
}
async function autoRefreshAntigravityQuotaInBackground() {
if (!props.providerId) return
if (provider.value?.provider_type !== 'antigravity') return
if (refreshingQuota.value) return
if (!shouldAutoRefreshAntigravityQuota()) return
// 检查 OAuth Token 是否即将过期Antigravity / Kiro
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
return key.oauth_invalid_at == null
&& typeof key.oauth_expires_at === 'number'
&& (key.oauth_expires_at - now) <= AUTO_TOKEN_REFRESH_SKEW_SECONDS
}
const hadCachedQuota = allKeys.value.some(({ key }) => (
key.is_active &&
key.upstream_metadata &&
hasAntigravityQuotaData(key.upstream_metadata)
))
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
const meta = key.upstream_metadata
const updatedAt = meta?.antigravity?.updated_at
const quotaByModel = meta?.antigravity?.quota_by_model
// 只要有一个活跃 key 没有配额/为空/过期,就刷新一次(接口会批量刷新所有活跃 key
if (!quotaByModel || typeof quotaByModel !== 'object' || Object.keys(quotaByModel).length === 0) {
return true
}
if (typeof updatedAt !== 'number' || (now - updatedAt) > 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 (!hasKiroQuotaData(key.upstream_metadata)) {
return true
}
}
return false
}
// 通用的自动刷新配额函数(支持 Codex、Antigravity 和 Kiro
async function autoRefreshQuotaInBackground() {
if (!props.providerId) return
if (refreshingQuota.value) return
const providerType = provider.value?.provider_type
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro') return
// 检查是否需要刷新
let shouldRefresh = false
if (providerType === 'codex') {
shouldRefresh = shouldAutoRefreshCodexQuota()
} else if (providerType === 'antigravity') {
shouldRefresh = shouldAutoRefreshAntigravityQuota()
} else if (providerType === 'kiro') {
shouldRefresh = shouldAutoRefreshKiroQuota()
}
if (!shouldRefresh) return
let hadCachedQuota = false
if (providerType === 'codex') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaData(key.upstream_metadata))
} else if (providerType === 'antigravity') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && key.upstream_metadata && hasAntigravityQuotaData(key.upstream_metadata))
} else if (providerType === 'kiro') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaData(key.upstream_metadata))
}
refreshingQuota.value = true
try {
@@ -1379,11 +1594,11 @@ async function autoRefreshAntigravityQuotaInBackground() {
if (result.success > 0) {
// 重新加载 keys 以更新配额显示
await loadEndpoints()
} else if (!hadCachedQuota) {
} else if (!hadCachedQuota && providerType === 'antigravity') {
showError('没有获取到配额信息请检查账号是否已授权、project_id 是否存在)', '提示')
}
} catch (err: any) {
if (!hadCachedQuota) {
if (!hadCachedQuota && providerType === 'antigravity') {
showError(err.response?.data?.detail || '后台刷新配额失败', '错误')
}
} finally {
@@ -1426,7 +1641,7 @@ async function handleKeyChanged() {
])
emit('refresh')
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
void autoRefreshAntigravityQuotaInBackground()
void autoRefreshQuotaInBackground()
}
// 切换密钥启用状态
@@ -1447,6 +1662,57 @@ async function toggleKeyActive(key: EndpointAPIKey) {
}
}
// ===== 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('代理节点已设置')
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || '设置代理失败', '错误')
} 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('已清除账号代理,将使用提供商级别代理')
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || '清除代理失败', '错误')
} finally {
savingProxyKeyId.value = null
}
}
// ===== 模型事件处理 =====
// 处理编辑模型
function handleEditModel(model: Model) {
@@ -1803,12 +2069,11 @@ function getQuotaRemainingBarColor(usedPercent: number): string {
return 'bg-green-500 dark:bg-green-400'
}
// 检查是否 Codex 额度数据
function hasCodexQuotaData(metadata: UpstreamMetadata | null | undefined): boolean {
if (!metadata) return false
return metadata.primary_used_percent !== undefined ||
metadata.secondary_used_percent !== undefined ||
(metadata.has_credits && metadata.credits_balance !== undefined)
// 判断是否 Codex Team/Plus/Enterprise 账号(有 5H 限额,显示 3 列)
function isCodexTeamPlan(key: EndpointAPIKey): boolean {
const planType = key.oauth_plan_type?.toLowerCase() || key.upstream_metadata?.codex?.plan_type?.toLowerCase()
// Free 账号返回 false2 列),其他所有账号返回 true3 列)
return planType !== undefined && planType !== 'free'
}
interface AntigravityQuotaItem {
@@ -1824,7 +2089,7 @@ function hasAntigravityQuotaData(metadata: UpstreamMetadata | null | undefined):
return !!quotaByModel && typeof quotaByModel === 'object' && Object.keys(quotaByModel).length > 0
}
function formatAntigravityUpdatedAt(updatedAt: number): string {
function formatUpdatedAt(updatedAt: number): string {
if (!updatedAt || typeof updatedAt !== 'number') return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - updatedAt
@@ -1837,6 +2102,10 @@ function formatAntigravityUpdatedAt(updatedAt: number): string {
return `${days}天前更新`
}
// 兼容旧函数名
const formatCodexUpdatedAt = formatUpdatedAt
const formatAntigravityUpdatedAt = formatUpdatedAt
function secondsUntilReset(resetTime: string): number | null {
if (!resetTime) return null
const ts = Date.parse(resetTime)
@@ -1982,6 +2251,8 @@ function getOAuthPlanTypeClass(planType: string): string {
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',
}
return classes[planType.toLowerCase()] || ''
}

View File

@@ -37,7 +37,7 @@
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
<!-- 新建模式允许自定义Codex Antigravity -->
<!-- 新建模式允许自定义CodexKiro Antigravity -->
<template v-if="!isEditMode">
<SelectItem value="custom">
自定义
@@ -45,6 +45,9 @@
<SelectItem value="codex">
Codex
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
@@ -63,6 +66,9 @@
<SelectItem value="gemini_cli">
GeminiCli
</SelectItem>
<SelectItem value="kiro">
Kiro
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
@@ -337,7 +343,7 @@ const defaultPriority = computed(() => {
// 表单数据
const form = ref({
name: '',
provider_type: 'custom' as 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity',
provider_type: 'custom' as 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro',
description: '',
website: '',
// 计费配置

View File

@@ -512,7 +512,7 @@ function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
}))
}
function copyVideoPricingFromSelectedGlobal() {
function _copyVideoPricingFromSelectedGlobal() {
const gm = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
const cfg = gm?.config || {}
if (cfg && typeof cfg === 'object') {

View File

@@ -387,7 +387,7 @@ const preselectedModelId = ref<string | null>(null)
const providerEndpoints = ref<ProviderEndpoint[]>([])
// Key 数据(用于判断支持的格式)
const providerKeys = ref<EndpointAPIKey[]>([])
const providerKeysState = ref<EndpointAPIKey[]>([])
// 测试下拉菜单状态
const formatMenuOpen = ref<Record<string, boolean>>({})
@@ -397,7 +397,7 @@ const expandedItems = ref<Set<number>>(new Set())
// 是否有 key 配置了自动获取上游模型
const hasAutoFetchKey = computed(() => {
const keys = props.providerKeys || providerKeys.value
const keys = props.providerKeys || providerKeysState.value
return keys.some(k => k.auto_fetch_models)
})
@@ -536,7 +536,7 @@ async function loadData() {
models.value = modelsData
aliasMappingPreview.value = previewData
providerEndpoints.value = endpointsData
providerKeys.value = keysData
providerKeysState.value = keysData
} catch (err: any) {
showError(err.response?.data?.detail || '加载失败', '错误')
} finally {
@@ -645,7 +645,7 @@ function getItemAvailableFormats(item: CombinedMapping): string[] {
const mappingFormats = item.group.apiFormats
// 找到所有支持该映射格式的活跃 Key
const supportingKeys = providerKeys.value.filter(key => {
const supportingKeys = providerKeysState.value.filter(key => {
if (!key.is_active) return false
// Key 的 api_formats 与映射的 apiFormats 有交集
return key.api_formats?.some(fmt => mappingFormats.includes(fmt))
@@ -669,7 +669,7 @@ function getItemAvailableFormats(item: CombinedMapping): string[] {
// 正则映射或无限制:返回所有有活跃 Key 支持的端点格式
const allKeyFormats = new Set<string>()
for (const key of providerKeys.value) {
for (const key of providerKeysState.value) {
if (!key.is_active) continue
for (const fmt of key.api_formats || []) {
allKeyFormats.add(fmt)

View File

@@ -766,7 +766,7 @@ const currentTierIndex = computed(() => {
})
// 总输入上下文(输入 + 缓存创建 + 缓存读取)
const totalInputContext = computed(() => {
const _totalInputContext = computed(() => {
if (!detail.value) return 0
// 优先使用 tiered_pricing 中的值

View File

@@ -143,6 +143,12 @@
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
</SelectItem>
</SelectContent>
</Select>

View File

@@ -481,7 +481,7 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
})
// 用户名验证
const usernameRegex = /^[a-zA-Z0-9_.\-]+$/
const usernameRegex = /^[a-zA-Z0-9_.-]+$/
const usernameError = computed(() => {
const username = form.value.username.trim()
if (!username) return ''

View File

@@ -365,7 +365,6 @@ import {
X,
Mail,
Puzzle,
Video,
Zap,
FileUp,
Server,

View File

@@ -784,10 +784,10 @@ function clearFilters() {
const skip = computed(() => (currentPage.value - 1) * limit.value)
const activeKeyCount = computed(() => apiKeys.value.filter(key => key.is_active).length)
const inactiveKeyCount = computed(() => Math.max(0, apiKeys.value.length - activeKeyCount.value))
const _inactiveKeyCount = computed(() => Math.max(0, apiKeys.value.length - activeKeyCount.value))
const limitedKeyCount = computed(() => apiKeys.value.filter(isBalanceLimited).length)
const unlimitedKeyCount = computed(() => Math.max(0, apiKeys.value.length - limitedKeyCount.value))
const expiringSoonCount = computed(() => apiKeys.value.filter(key => isExpiringSoon(key)).length)
const _unlimitedKeyCount = computed(() => Math.max(0, apiKeys.value.length - limitedKeyCount.value))
const _expiringSoonCount = computed(() => apiKeys.value.filter(key => isExpiringSoon(key)).length)
// 筛选后的 API Keys
const filteredApiKeys = computed(() => {

View File

@@ -18,10 +18,10 @@ import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import SelectValue from '@/components/ui/select-value.vue'
import ScatterChart from '@/components/charts/ScatterChart.vue'
import { Trash2, Eraser, Search, X, BarChart3, ChevronDown, ChevronRight, Database, ArrowRight } from 'lucide-vue-next'
import { Trash2, Eraser, Search, X, BarChart3, ChevronDown, ChevronRight, Database, ArrowRight, HardDrive } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { cacheApi, modelMappingCacheApi, type CacheStats, type CacheConfig, type UserAffinity, type ModelMappingCacheStats } from '@/api/cache'
import { cacheApi, modelMappingCacheApi, redisCacheApi, type CacheStats, type CacheConfig, type UserAffinity, type ModelMappingCacheStats, type RedisCacheCategoriesResponse } from '@/api/cache'
import type { TTLAnalysisUser } from '@/api/cache'
import { formatNumber, formatTokens, formatCost, formatRemainingTime } from '@/utils/format'
import {
@@ -54,6 +54,12 @@ const modelMappingLoading = ref(false)
const clearingModelMapping = ref(false)
const clearingModelName = ref<string | null>(null)
// ==================== Redis 缓存分类管理 ====================
const redisCacheData = ref<RedisCacheCategoriesResponse | null>(null)
const redisCacheLoading = ref(false)
const clearingCategory = ref<string | null>(null)
const { success: showSuccess, error: showError, info: showInfo } = useToast()
const { confirm: showConfirm } = useConfirm()
@@ -324,6 +330,54 @@ async function clearProviderModelMapping(providerId: string, globalModelId: stri
}
}
// ==================== Redis 缓存分类管理方法 ====================
async function fetchRedisCacheCategories() {
redisCacheLoading.value = true
try {
redisCacheData.value = await redisCacheApi.getCategories()
} catch (error) {
showError('获取 Redis 缓存分类失败')
log.error('获取 Redis 缓存分类失败', error)
} finally {
redisCacheLoading.value = false
}
}
async function clearRedisCategory(categoryKey: string, categoryName: string, count: number) {
if (count === 0) {
showInfo(`${categoryName} 缓存为空,无需清理`)
return
}
const confirmed = await showConfirm({
title: `清除 ${categoryName} 缓存`,
message: `确定要清除 ${categoryName} 的所有缓存吗?共 ${count} 个键。`,
})
if (!confirmed) return
clearingCategory.value = categoryKey
try {
const result = await redisCacheApi.clearCategory(categoryKey)
showSuccess(`已清除 ${categoryName} 缓存(${result.deleted_count} 个键)`)
await fetchRedisCacheCategories()
} catch (error) {
showError(`清除 ${categoryName} 缓存失败`)
log.error('清除 Redis 缓存分类失败', error)
} finally {
clearingCategory.value = null
}
}
const redisCategoriesWithKeys = computed(() => {
if (!redisCacheData.value?.categories) return []
return redisCacheData.value.categories.filter(c => c.count > 0)
})
const redisCategoriesEmpty = computed(() => {
if (!redisCacheData.value?.categories) return []
return redisCacheData.value.categories.filter(c => c.count === 0)
})
function formatTTL(ttl: number | null): string {
if (ttl === null || ttl < 0) return '-'
if (ttl < 60) return `${ttl}s`
@@ -353,7 +407,8 @@ async function refreshData() {
fetchCacheStats(),
fetchCacheConfig(),
fetchAffinityList(),
fetchModelMappingStats()
fetchModelMappingStats(),
fetchRedisCacheCategories()
])
}
@@ -379,6 +434,7 @@ onMounted(() => {
fetchCacheConfig()
fetchAffinityList()
fetchModelMappingStats()
fetchRedisCacheCategories()
startCountdown()
refreshAnalysis()
})
@@ -1045,6 +1101,108 @@ onBeforeUnmount(() => {
</div>
</Card>
<!-- Redis 缓存分类管理 -->
<Card class="overflow-hidden">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="flex items-center gap-3 shrink-0">
<HardDrive class="h-5 w-5 text-muted-foreground hidden sm:block" />
<h3 class="text-sm sm:text-base font-semibold">
Redis 缓存管理
</h3>
<Badge
v-if="redisCacheData?.total_keys !== undefined"
variant="secondary"
>
{{ redisCacheData.total_keys }} 个键
</Badge>
</div>
<div class="flex items-center gap-2">
<RefreshButton
:loading="redisCacheLoading"
size="sm"
title="刷新缓存分类"
@click="fetchRedisCacheCategories"
/>
</div>
</div>
</div>
<!-- 有数据 -->
<div v-if="redisCacheData?.available && redisCacheData.categories.length > 0">
<!-- 有缓存的分类 -->
<div
v-if="redisCategoriesWithKeys.length > 0"
class="divide-y divide-border/40"
>
<div
v-for="cat in redisCategoriesWithKeys"
:key="cat.key"
class="flex items-center justify-between px-4 sm:px-6 py-2.5 hover:bg-muted/30 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{{ cat.name }}</span>
<Badge variant="outline">
{{ cat.count }}
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-0.5 truncate">
{{ cat.description }}
</p>
</div>
<Button
variant="ghost"
size="sm"
class="shrink-0 ml-3 text-destructive hover:text-destructive hover:bg-destructive/10"
:disabled="clearingCategory === cat.key"
title="清除该分类缓存"
@click="clearRedisCategory(cat.key, cat.name, cat.count)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- 空分类折叠 -->
<div
v-if="redisCategoriesEmpty.length > 0"
class="px-4 sm:px-6 py-3 border-t border-border/40"
>
<p class="text-xs text-muted-foreground">
另有 {{ redisCategoriesEmpty.length }} 个分类为空{{ redisCategoriesEmpty.map(c => c.name).join('、') }}
</p>
</div>
<!-- 全部为空 -->
<div
v-if="redisCategoriesWithKeys.length === 0"
class="px-6 py-8 text-center"
>
<HardDrive class="h-10 w-10 text-muted-foreground/30 mx-auto mb-2" />
<p class="text-sm text-muted-foreground">
所有缓存分类为空
</p>
</div>
</div>
<!-- Redis 不可用 -->
<div
v-else-if="redisCacheData && !redisCacheData.available"
class="px-6 py-8 text-center text-sm text-muted-foreground"
>
{{ redisCacheData.message || 'Redis 未启用' }}
</div>
<!-- 加载中 -->
<div
v-else-if="redisCacheLoading"
class="px-6 py-8 text-center text-sm text-muted-foreground"
>
正在扫描 Redis 缓存...
</div>
</Card>
<!-- TTL 分析区域 -->
<Card class="overflow-hidden">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">

View File

@@ -499,10 +499,6 @@ import {
Trash2,
Loader2,
Eye,
Wrench,
Brain,
Zap,
Image,
Building2,
Search,
Power,
@@ -648,7 +644,7 @@ function hasVideoPricing(model: GlobalModelResponse): boolean {
}
// 获取视频分辨率计费的数量
function getVideoPricingCount(model: GlobalModelResponse): number {
function _getVideoPricingCount(model: GlobalModelResponse): number {
const priceByResolution = model.config?.billing?.video?.price_per_second_by_resolution
if (!priceByResolution || typeof priceByResolution !== 'object') return 0
return Object.keys(priceByResolution).length

View File

@@ -3,7 +3,7 @@ import { RouterLink } from 'vue-router'
import { ArrowRight, Shuffle, FileCode, Globe, Shield, Check, Info, AlertTriangle, Settings } from 'lucide-vue-next'
import { panelClasses } from './guide-config'
const props = withDefaults(
withDefaults(
defineProps<{
baseUrl?: string
}>(),

View File

@@ -3,7 +3,7 @@ import { ref, computed } from 'vue'
import { Search, ChevronDown, ExternalLink, HelpCircle } from 'lucide-vue-next'
import { faqItems, panelClasses } from './guide-config'
const props = withDefaults(
withDefaults(
defineProps<{
baseUrl?: string
}>(),
@@ -18,12 +18,6 @@ const searchQuery = ref('')
// 展开的 FAQ
const expandedIds = ref<Set<string>>(new Set())
// 分类列表
const categories = computed(() => {
const cats = new Set(faqItems.map(item => item.category))
return Array.from(cats)
})
// 过滤后的 FAQ
const filteredFaqs = computed(() => {
if (!searchQuery.value.trim()) {

View File

@@ -3,7 +3,7 @@ import { RouterLink } from 'vue-router'
import { ArrowRight, Layers, Check, Info, Shuffle, TrendingUp, Gauge, Clock } from 'lucide-vue-next'
import { loadBalanceModes, panelClasses } from './guide-config'
const props = withDefaults(
withDefaults(
defineProps<{
baseUrl?: string
}>(),

View File

@@ -3,7 +3,7 @@ import { RouterLink } from 'vue-router'
import { ArrowRight, Server, Layers, Key, Box, ChevronRight } from 'lucide-vue-next'
import { coreConcepts, apiFormats, configSteps, panelClasses } from './guide-config'
const props = withDefaults(
withDefaults(
defineProps<{
baseUrl?: string
}>(),

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { ArrowRight, Server, Plus, Settings, Check, AlertTriangle, Info } from 'lucide-vue-next'
import { ArrowRight, Server, Settings, Check, AlertTriangle, Info } from 'lucide-vue-next'
import { apiFormats, panelClasses } from './guide-config'
const props = withDefaults(
withDefaults(
defineProps<{
baseUrl?: string
}>(),

View File

@@ -3,7 +3,7 @@ import { RouterLink } from 'vue-router'
import { ArrowRight, Users, Key, Shield, Check, Info, AlertTriangle, Clock } from 'lucide-vue-next'
import { panelClasses } from './guide-config'
const props = withDefaults(
withDefaults(
defineProps<{
baseUrl?: string
}>(),
@@ -12,14 +12,6 @@ const props = withDefaults(
}
)
// 用户字段说明
const userFields = [
{ name: '用户名/邮箱', description: '用户的登录凭证', required: true },
{ name: '角色', description: '普通用户或管理员', required: true },
{ name: '状态', description: '启用/禁用用户', required: false },
{ name: '默认配额', description: '该用户创建的 Key 默认继承的配额设置', required: false }
]
// API Key 字段说明
const keyFields = [
{ name: '名称', description: 'Key 的描述性名称,方便识别', required: true },

View File

@@ -262,9 +262,6 @@
import { ref, computed, onMounted, watch } from 'vue'
import {
Loader2,
Eye,
Wrench,
Brain,
Search,
Copy,
Check,