mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -70,7 +70,7 @@ interface Props {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
||||
// (不 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 账号返回 false(2 列),其他所有账号返回 true(3 列)
|
||||
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()] || ''
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<SelectValue placeholder="请选择" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<!-- 新建模式:允许自定义、Codex 和 Antigravity -->
|
||||
<!-- 新建模式:允许自定义、Codex、Kiro 和 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: '',
|
||||
// 计费配置
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -766,7 +766,7 @@ const currentTierIndex = computed(() => {
|
||||
})
|
||||
|
||||
// 总输入上下文(输入 + 缓存创建 + 缓存读取)
|
||||
const totalInputContext = computed(() => {
|
||||
const _totalInputContext = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
|
||||
// 优先使用 tiered_pricing 中的值
|
||||
|
||||
@@ -143,6 +143,12 @@
|
||||
<SelectItem value="cancelled">
|
||||
已取消
|
||||
</SelectItem>
|
||||
<SelectItem value="has_retry">
|
||||
发生重试
|
||||
</SelectItem>
|
||||
<SelectItem value="has_fallback">
|
||||
发生转移
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -365,7 +365,6 @@ import {
|
||||
X,
|
||||
Mail,
|
||||
Puzzle,
|
||||
Video,
|
||||
Zap,
|
||||
FileUp,
|
||||
Server,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}>(),
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
}>(),
|
||||
|
||||
@@ -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
|
||||
}>(),
|
||||
|
||||
@@ -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
|
||||
}>(),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -262,9 +262,6 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Loader2,
|
||||
Eye,
|
||||
Wrench,
|
||||
Brain,
|
||||
Search,
|
||||
Copy,
|
||||
Check,
|
||||
|
||||
Reference in New Issue
Block a user