Merge remote-tracking branch 'origin/main'

This commit is contained in:
fawney19
2026-05-20 16:14:09 +08:00
45 changed files with 4099 additions and 164 deletions

View File

@@ -286,6 +286,74 @@ export async function refreshProviderQuota(
return response.data
}
export type ProviderKeyBalanceStatus =
| 'success'
| 'pending'
| 'auth_failed'
| 'auth_expired'
| 'rate_limited'
| 'network_error'
| 'parse_error'
| 'not_configured'
| 'not_supported'
| 'already_done'
| 'unknown_error'
export interface ProviderKeyBalanceInfo {
total_granted: number | null
total_used: number | null
total_available: number | null
expires_at: string | null
currency: string
extra: Record<string, unknown>
}
export interface ProviderKeyBalanceResult {
status: ProviderKeyBalanceStatus
action_type: 'query_balance'
data: ProviderKeyBalanceInfo | null
message: string | null
executed_at: string
response_time_ms: number | null
cache_ttl_seconds: number
saved_to_key?: boolean
saved_key_id?: string | null
save_message?: string | null
}
export interface ProviderKeyBalanceQuery {
key_id?: string
api_key?: string
auth_type?: 'api_key' | 'bearer' | 'service_account' | 'oauth'
api_formats?: string[]
architecture_id?: 'new_api' | 'sub2api' | 'generic_api'
custom_base_url?: string
new_api_user_id?: string
sub2api_credential_kind?: 'api_key' | 'access_token' | 'refresh_token'
custom_endpoint?: string
custom_method?: 'GET' | 'POST'
custom_currency?: string
custom_quota_divisor?: number
custom_balance_path?: string
custom_used_path?: string
custom_granted_path?: string
auto_refresh_interval_minutes?: number
save_balance_secret?: boolean
save_result?: boolean
}
export async function queryProviderKeyBalance(
providerId: string,
data: ProviderKeyBalanceQuery,
): Promise<ProviderKeyBalanceResult> {
const response = await client.post<ProviderKeyBalanceResult>(
`/api/admin/endpoints/providers/${providerId}/key-balance`,
data,
{ timeout: 60 * 1000 },
)
return response.data
}
/**
* 批量导入 OAuth 凭据(通用)
* 支持的 Provider 类型Codex、Antigravity、GeminiCli、ClaudeCode、Kiro

View File

@@ -402,12 +402,46 @@ export interface GrokUpstreamMetadata {
account_user_id?: string | null
}
export interface BalanceQueryUpstreamMetadata {
updated_at?: number
architecture_id?: string | null
status?: string | null
executed_at?: string | null
response_time_ms?: number | null
total_available?: number | null
total_used?: number | null
total_granted?: number | null
currency?: string | null
plan_name?: string | null
query_config?: {
custom_base_url?: string | null
new_api_user_id?: string | null
sub2api_credential_kind?: 'api_key' | 'access_token' | 'refresh_token' | string | null
custom_endpoint?: string | null
custom_method?: 'GET' | 'POST' | string | null
custom_currency?: string | null
custom_quota_divisor?: number | null
custom_balance_path?: string | null
custom_used_path?: string | null
custom_granted_path?: string | null
auto_refresh_interval_minutes?: number | null
has_saved_secret?: boolean | null
} | null
extra?: Record<string, unknown> | null
}
export interface ProviderKeyBalanceSummary extends BalanceQueryUpstreamMetadata {
key_id?: string | null
key_name?: string | null
}
export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
chatgpt_web?: ChatGPTWebUpstreamMetadata
grok?: GrokUpstreamMetadata
balance_query?: BalanceQueryUpstreamMetadata
}
// 按格式的健康度数据
@@ -684,6 +718,7 @@ export interface ProviderWithEndpointsSummary {
failover_rules?: FailoverRulesConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
key_balance_summary?: ProviderKeyBalanceSummary | null
kiro_simulated_cache_enabled?: boolean
created_at: string
updated_at: string

View File

@@ -12,7 +12,13 @@ import client from './client'
// ==================== Types ====================
/** 认证类型 */
export type ConnectorAuthType = 'api_key' | 'session_login' | 'oauth' | 'cookie' | 'none'
export type ConnectorAuthType =
| 'api_key'
| 'refresh_token'
| 'session_login'
| 'oauth'
| 'cookie'
| 'none'
/** 操作类型 */
export type ProviderActionType =

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
<template>
<Dialog
:open="open"
title="用户认证"
description="配置提供商的用户认证信息,用于余额查询、签到等操作"
:title="dialogTitle"
description="独立配置上游余额/用量查询凭据,不影响模型调用 Key"
:icon="KeyRound"
size="md"
size="4xl"
@update:open="$emit('update:open', $event)"
>
<form
@@ -23,38 +23,30 @@
</div>
<div
v-else
class="space-y-4"
class="space-y-5"
>
<!-- 认证模板 + 认证方式并排 -->
<div class="flex gap-3">
<div
class="space-y-2"
:style="{ flex: currentAuthTypes.length > 1 ? 1 : 'auto', width: currentAuthTypes.length > 1 ? undefined : '100%' }"
>
<Label>认证模板</Label>
<Select
v-model="selectedArchitectureId"
@update:model-value="handleArchitectureChange"
>
<SelectTrigger>
<SelectValue placeholder="选择认证模板" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="arch in architectures"
:key="arch.architecture_id"
:value="arch.architecture_id"
>
{{ arch.display_name }}
</SelectItem>
</SelectContent>
</Select>
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<Label>预设模板</Label>
<span class="text-xs text-muted-foreground">留空则自动使用供应商配置</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
v-for="arch in architectures"
:key="arch.architecture_id"
type="button"
class="h-8 rounded-md border px-3 text-xs font-medium transition-colors"
:class="selectedArchitectureId === arch.architecture_id
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground'"
@click="selectArchitecturePreset(arch.architecture_id)"
>
{{ formatArchitectureLabel(arch) }}
</button>
</div>
<div
v-if="currentAuthTypes.length > 1"
class="space-y-2"
style="flex: 1"
class="grid gap-2 sm:max-w-xs"
>
<Label>认证方式</Label>
<Select
@@ -77,6 +69,15 @@
</div>
</div>
<div class="space-y-1">
<div class="text-sm font-semibold text-foreground">
凭证配置
</div>
<div class="text-xs text-muted-foreground">
不同模板支持的凭据类型不同API Key访问令牌和 Refresh Token 会分别保留
</div>
</div>
<!-- 动态表单字段 -->
<template v-if="currentSchema">
<template
@@ -262,13 +263,23 @@
:disabled="isVerifying || !canVerify"
@click="handleVerify"
>
{{ isVerifying ? '验证中...' : '验证' }}
<Loader2
v-if="isVerifying"
class="h-3.5 w-3.5 animate-spin"
/>
<Play
v-else
class="h-3.5 w-3.5"
/>
{{ isVerifying ? '测试中...' : '测试脚本' }}
</Button>
<Button
:disabled="isSaving || !canSave"
@click="handleSave"
variant="outline"
:disabled="isSaving || isVerifying"
@click="handleFormat"
>
{{ isSaving ? '保存中...' : '保存' }}
<Wand2 class="h-3.5 w-3.5" />
格式化
</Button>
<Button
variant="outline"
@@ -276,6 +287,20 @@
>
取消
</Button>
<Button
:disabled="isSaving || !canSave"
@click="handleSave"
>
<Loader2
v-if="isSaving"
class="h-3.5 w-3.5 animate-spin"
/>
<Save
v-else
class="h-3.5 w-3.5"
/>
{{ isSaving ? '保存中...' : '保存配置' }}
</Button>
</div>
</div>
</template>
@@ -284,7 +309,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { KeyRound } from 'lucide-vue-next'
import { KeyRound, Loader2, Play, Save, Wand2 } from 'lucide-vue-next'
import {
Dialog,
Button,
@@ -325,6 +350,7 @@ import { useProxyNodesStore } from '@/stores/proxy-nodes'
const props = defineProps<{
open: boolean
providerId: string
providerName?: string
providerWebsite?: string
currentConfig?: Record<string, unknown> | null
}>()
@@ -376,6 +402,12 @@ const selectedArchitectureId = ref('new_api')
const selectedAuthType = ref('')
const formData = ref<Record<string, unknown>>({})
const dialogTitle = computed(() => (
props.providerName
? `配置用量查询 - ${props.providerName}`
: '配置用量查询'
))
// 当前架构支持的认证方式
const currentAuthTypes = computed(() => {
const arch = architectures.value.find((a) => a.architecture_id === selectedArchitectureId.value)
@@ -441,6 +473,26 @@ function handleArchitectureChange() {
formChanged.value = true
}
function selectArchitecturePreset(architectureId: string) {
if (selectedArchitectureId.value === architectureId) return
selectedArchitectureId.value = architectureId
handleArchitectureChange()
}
function formatArchitectureLabel(arch: ArchitectureInfo): string {
const labels: Record<string, string> = {
generic_api: '通用模板',
new_api: 'NewAPI',
sub2api: 'Sub2API',
anyrouter: 'AnyRouter',
done_hub: 'Done Hub',
yescode: 'YesCode',
cubence: 'Cubence',
nekocode: 'NekoCode',
}
return labels[arch.architecture_id] || arch.display_name
}
function handleAuthTypeChange() {
resetFormData()
verifyStatus.value = null
@@ -478,7 +530,9 @@ function resetFormData() {
// 初始化表单数据
const data: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(schema.properties)) {
data[key] = (prop as Record<string, unknown>)['x-default-value'] ?? ''
data[key] = key === 'base_url'
? (props.providerWebsite || (prop as Record<string, unknown>)['x-default-value'] || '')
: ((prop as Record<string, unknown>)['x-default-value'] ?? '')
}
// 代理相关默认值
data.proxy_enabled = false
@@ -495,6 +549,22 @@ function formatQuota(quota: number): string {
return quota.toLocaleString()
}
function handleFormat() {
const normalized: Record<string, unknown> = { ...formData.value }
for (const [key, value] of Object.entries(normalized)) {
if (typeof value !== 'string') continue
normalized[key] = key === 'base_url'
? value.trim().replace(/\/+$/, '')
: value.trim()
}
if (!normalized.base_url && props.providerWebsite) {
normalized.base_url = props.providerWebsite.replace(/\/+$/, '')
}
formData.value = normalized
verifyStatus.value = null
formChanged.value = true
}
async function handleVerify() {
const schema = currentSchema.value
if (!schema) return
@@ -677,6 +747,10 @@ function loadFromConfig(config: Record<string, unknown>) {
if (!config?.connector) return
hasExistingConfig.value = true
const connector = config.connector as {
auth_type?: string
credentials?: Record<string, unknown>
}
// 根据已保存的 architecture_id 选择对应架构
const architectureId = config.architecture_id || 'new_api'
@@ -684,7 +758,15 @@ function loadFromConfig(config: Record<string, unknown>) {
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
// 从已保存的 connector auth_type 恢复认证方式选择
const savedAuthType = config.connector?.auth_type
let savedAuthType = connector?.auth_type
if (
selectedArchitectureId.value === 'sub2api' &&
savedAuthType === 'api_key' &&
connector?.credentials?.refresh_token &&
!connector?.credentials?.api_key
) {
savedAuthType = 'refresh_token'
}
const authTypes = currentAuthTypes.value
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
selectedAuthType.value = savedAuthType
@@ -775,4 +857,14 @@ watch(
}
}
)
watch(
() => props.providerWebsite,
(value) => {
if (!props.open || hasExistingConfig.value || !value) return
if (!formData.value.base_url) {
formData.value.base_url = value
}
}
)
</script>

View File

@@ -1,15 +1,7 @@
<template>
<!-- 余额正在加载中 -->
<div
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Loader2 class="h-3 w-3 animate-spin" />
<span>加载中...</span>
</div>
<!-- 显示从上游 API 查询的余额 -->
<div
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
v-if="provider.ops_configured && getProviderBalance(provider.id)"
class="flex items-center gap-2 text-xs"
>
<!-- 余额文字balance + points 分开显示或普通余额 -->
@@ -95,6 +87,35 @@
</div>
</div>
</div>
<!-- 显示保存到 Key 的手动余额查询摘要 -->
<div
v-else-if="getSavedKeyBalance(provider)"
class="space-y-0.5 text-xs"
:title="getSavedKeyBalanceTitle(provider)"
>
<div class="flex items-center gap-1.5">
<WalletCards class="h-3 w-3 text-primary" />
<span class="font-semibold text-foreground/90 tabular-nums">
{{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_available, getSavedKeyBalance(provider)?.currency || 'USD') }}
</span>
</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px] text-muted-foreground/70">
<span v-if="toFiniteNumber(getSavedKeyBalance(provider)?.total_used) !== null">
已用 {{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_used, getSavedKeyBalance(provider)?.currency || 'USD') }}
</span>
<span>
{{ keyBalanceTemplateLabel(getSavedKeyBalance(provider)?.architecture_id) }} · {{ formatKeyBalanceUpdatedAt(getSavedKeyBalance(provider)?.updated_at) }}
</span>
</div>
</div>
<!-- 余额正在加载中 -->
<div
v-else-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Loader2 class="h-3 w-3 animate-spin" />
<span>加载中...</span>
</div>
<!-- 余额查询失败时显示错误 -->
<div
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
@@ -128,11 +149,18 @@
</template>
<script setup lang="ts">
import { Loader2 } from 'lucide-vue-next'
import { Loader2, WalletCards } from 'lucide-vue-next'
import Badge from '@/components/ui/badge.vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import type { ProviderKeyBalanceSummary, ProviderWithEndpointsSummary } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
import {
formatKeyBalanceAmount,
formatKeyBalanceUpdatedAt,
hasKeyBalanceSummary,
keyBalanceTemplateLabel,
toFiniteNumber,
} from '@/features/providers/utils/keyBalanceSummary'
defineProps<{
provider: ProviderWithEndpointsSummary
@@ -147,4 +175,19 @@ defineProps<{
formatResetCountdown: (resetsAt: number) => string
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
}>()
function getSavedKeyBalance(provider: ProviderWithEndpointsSummary): ProviderKeyBalanceSummary | null {
return hasKeyBalanceSummary(provider.key_balance_summary) ? provider.key_balance_summary : null
}
function getSavedKeyBalanceTitle(provider: ProviderWithEndpointsSummary): string {
const summary = getSavedKeyBalance(provider)
if (!summary) return ''
const parts = [
summary.key_name ? `Key: ${summary.key_name}` : null,
keyBalanceTemplateLabel(summary.architecture_id),
formatKeyBalanceUpdatedAt(summary.updated_at),
].filter(Boolean)
return parts.join(' · ')
}
</script>

View File

@@ -555,6 +555,64 @@
</Button>
</div>
</div>
<!-- 手动余额查询摘要 -->
<div
v-if="getKeyBalanceSummary(key)"
class="mt-2 flex items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-[11px]"
>
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1">
<span class="inline-flex items-center gap-1 font-medium text-foreground">
<WalletCards class="h-3 w-3 text-primary" />
上游余额 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.available, getKeyBalanceSummary(key)?.currency) }}
</span>
<span
v-if="getKeyBalanceSummary(key)?.used !== null"
class="text-muted-foreground"
>
已用 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.used, getKeyBalanceSummary(key)?.currency) }}
</span>
<span
v-if="getKeyBalanceSummary(key)?.granted !== null"
class="text-muted-foreground"
>
总额 {{ formatKeyBalanceAmount(getKeyBalanceSummary(key)?.granted, getKeyBalanceSummary(key)?.currency) }}
</span>
<span
v-if="getKeyBalanceSummary(key)?.planName"
class="text-muted-foreground"
>
套餐 {{ getKeyBalanceSummary(key)?.planName }}
</span>
<span class="text-muted-foreground/70">
{{ getKeyBalanceSummary(key)?.templateLabel }} · {{ formatUpdatedAt(getKeyBalanceSummary(key)?.updatedAt || 0) }}
</span>
<span
v-if="getKeyBalanceAutoRefreshIntervalMinutes(key) > 0"
class="text-muted-foreground/70"
>
每 {{ getKeyBalanceAutoRefreshIntervalMinutes(key) }} 分钟自动
</span>
<span
v-if="keyBalanceRefreshRequiresSavedSecret(key) && !hasSavedBalanceSecret(key)"
class="text-amber-600 dark:text-amber-400"
>
需保存查询凭据
</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground"
:disabled="refreshingBalanceKeyId === key.id || !canRefreshKeyBalance(key)"
:title="getKeyBalanceRefreshTitle(key)"
@click.stop="handleRefreshKeyBalance(key)"
>
<RefreshCw
class="h-3 w-3"
:class="{ 'animate-spin': refreshingBalanceKeyId === key.id }"
/>
</Button>
</div>
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
<div
v-if="hasCodexQuotaDisplayData(key)"
@@ -1217,7 +1275,7 @@
</template>
<script setup lang="ts">
import { ref, watch, computed, nextTick } from 'vue'
import { ref, watch, computed, nextTick, onUnmounted } from 'vue'
import {
Plus,
Key,
@@ -1236,6 +1294,7 @@ import {
ShieldX,
Globe,
GitBranch,
WalletCards,
} from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -1282,10 +1341,12 @@ import {
exportKey,
refreshProviderOAuth,
refreshProviderQuota,
queryProviderKeyBalance,
clearOAuthInvalid,
type ProviderEndpoint,
type EndpointAPIKey,
type Model,
type ProviderKeyBalanceQuery,
API_FORMAT_ORDER,
sortApiFormats,
} from '@/api/endpoints'
@@ -1330,6 +1391,17 @@ interface ProviderEndpointWithKeys extends ProviderEndpoint {
rpm_limit?: number
}
interface KeyBalanceSummary {
available: number | null
used: number | null
granted: number | null
currency: string
updatedAt: number
templateLabel: string
planName: string | null
architectureId: string
}
interface Props {
providerId: string | null
open: boolean
@@ -1365,6 +1437,8 @@ let keysLoadRequestId = 0
let mappingPreviewLoadRequestId = 0
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4
const BALANCE_AUTO_REFRESH_CHECK_MS = 60_000
let balanceAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
function getProviderKeysPageSize(providerType?: string | null): number {
return (providerType || '').trim().toLowerCase() === 'custom'
@@ -1388,6 +1462,7 @@ const editingKey = ref<EndpointAPIKey | null>(null)
const deleteKeyConfirmOpen = ref(false)
const keyToDelete = ref<EndpointAPIKey | null>(null)
const togglingKeyId = ref<string | null>(null)
const refreshingBalanceKeyId = ref<string | null>(null)
// 密钥显示状态key_id -> 完整密钥
const revealedKeys = ref<Map<string, string>>(new Map())
@@ -1570,6 +1645,7 @@ watch(
// 仅在抽屉刚打开时启动倒计时
if (newOpen && !oldOpen) {
startCountdownTimer()
startKeyBalanceAutoRefreshTimer()
}
void endpointsPromise.then(() => autoRefreshQuotaInBackground())
} else if (!newOpen && oldOpen) {
@@ -1581,6 +1657,7 @@ watch(
// 停止倒计时定时器
stopCountdownTimer()
stopKeyBalanceAutoRefreshTimer()
// 重置所有状态
loading.value = false
provider.value = null
@@ -1743,6 +1820,167 @@ function handleEditKey(endpoint: ProviderEndpoint | undefined, key: EndpointAPIK
}
}
function canOpenKeyBalanceQuery(key: EndpointAPIKey): boolean {
return key.auth_type === 'api_key' || key.auth_type === 'bearer'
}
function normalizeBalanceArchitectureId(value: unknown): ProviderKeyBalanceQuery['architecture_id'] | undefined {
const normalized = String(value || '').trim().toLowerCase().replace(/-/g, '_')
if (normalized === 'newapi' || normalized === 'new_api') return 'new_api'
if (normalized === 'sub2api') return 'sub2api'
if (normalized === 'generic' || normalized === 'custom' || normalized === 'generic_api') return 'generic_api'
return undefined
}
function canRefreshKeyBalance(key: EndpointAPIKey): boolean {
return canOpenKeyBalanceQuery(key)
&& !!normalizeBalanceArchitectureId(key.upstream_metadata?.balance_query?.architecture_id)
&& (!keyBalanceRefreshRequiresSavedSecret(key) || hasSavedBalanceSecret(key))
}
function hasSavedBalanceSecret(key: EndpointAPIKey): boolean {
return key.upstream_metadata?.balance_query?.query_config?.has_saved_secret === true
}
function keyBalanceRefreshRequiresSavedSecret(key: EndpointAPIKey): boolean {
const architectureId = normalizeBalanceArchitectureId(key.upstream_metadata?.balance_query?.architecture_id)
if (architectureId === 'new_api') return true
if (architectureId !== 'sub2api') return false
const credentialKind = String(
key.upstream_metadata?.balance_query?.query_config?.sub2api_credential_kind || ''
).trim()
return credentialKind === 'access_token' || credentialKind === 'refresh_token'
}
function getKeyBalanceAutoRefreshIntervalMinutes(key: EndpointAPIKey): number {
const parsed = toFiniteNumber(
key.upstream_metadata?.balance_query?.query_config?.auto_refresh_interval_minutes
)
if (parsed === null || parsed <= 0) return 0
return Math.min(Math.floor(parsed), 10080)
}
function isKeyBalanceAutoRefreshDue(key: EndpointAPIKey): boolean {
const intervalMinutes = getKeyBalanceAutoRefreshIntervalMinutes(key)
if (intervalMinutes <= 0 || !canRefreshKeyBalance(key)) return false
const updatedAt = toFiniteNumber(key.upstream_metadata?.balance_query?.updated_at)
if (updatedAt === null || updatedAt <= 0) return true
const now = Math.floor(Date.now() / 1000)
return now - updatedAt >= intervalMinutes * 60
}
function startKeyBalanceAutoRefreshTimer() {
if (balanceAutoRefreshTimer) return
balanceAutoRefreshTimer = setInterval(() => {
void refreshDueKeyBalances()
}, BALANCE_AUTO_REFRESH_CHECK_MS)
}
function stopKeyBalanceAutoRefreshTimer() {
if (!balanceAutoRefreshTimer) return
clearInterval(balanceAutoRefreshTimer)
balanceAutoRefreshTimer = null
}
async function refreshDueKeyBalances() {
if (!props.open || !props.providerId || refreshingBalanceKeyId.value) return
const dueKey = providerKeys.value.find(key => key.is_active && isKeyBalanceAutoRefreshDue(key))
if (!dueKey) return
await handleRefreshKeyBalance(dueKey, { silent: true })
}
function getKeyBalanceRefreshTitle(key: EndpointAPIKey): string {
if (!canOpenKeyBalanceQuery(key)) {
return '余额查询仅支持 API Key 或 Bearer Token'
}
if (!canRefreshKeyBalance(key)) {
if (keyBalanceRefreshRequiresSavedSecret(key) && !hasSavedBalanceSecret(key)) {
return '需要先手动查询一次,并开启“保存余额查询凭据”'
}
return '缺少上次查询模板,请先手动查询一次余额'
}
const summary = getKeyBalanceSummary(key)
return summary?.templateLabel
? `重新查询 ${summary.templateLabel} 余额`
: '重新查询余额'
}
function assignSavedBalanceQueryConfig(query: ProviderKeyBalanceQuery, key: EndpointAPIKey) {
const config = key.upstream_metadata?.balance_query?.query_config
if (!config || typeof config !== 'object') return
query.custom_base_url = trimmedStringOrUndefined(config.custom_base_url)
query.new_api_user_id = trimmedStringOrUndefined(config.new_api_user_id)
const sub2apiKind = String(config.sub2api_credential_kind || '').trim()
if (sub2apiKind === 'api_key' || sub2apiKind === 'access_token' || sub2apiKind === 'refresh_token') {
query.sub2api_credential_kind = sub2apiKind
}
query.custom_endpoint = trimmedStringOrUndefined(config.custom_endpoint)
const customMethod = String(config.custom_method || '').trim().toUpperCase()
if (customMethod === 'GET' || customMethod === 'POST') {
query.custom_method = customMethod
}
query.custom_currency = trimmedStringOrUndefined(config.custom_currency)
const customQuotaDivisor = toFiniteNumber(config.custom_quota_divisor)
if (customQuotaDivisor !== null && customQuotaDivisor > 0) {
query.custom_quota_divisor = customQuotaDivisor
}
const intervalMinutes = toFiniteNumber(config.auto_refresh_interval_minutes)
if (intervalMinutes !== null && intervalMinutes > 0) {
query.auto_refresh_interval_minutes = Math.min(Math.floor(intervalMinutes), 10080)
}
query.custom_balance_path = trimmedStringOrUndefined(config.custom_balance_path)
query.custom_used_path = trimmedStringOrUndefined(config.custom_used_path)
query.custom_granted_path = trimmedStringOrUndefined(config.custom_granted_path)
}
function trimmedStringOrUndefined(value: unknown): string | undefined {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed || undefined
}
async function handleRefreshKeyBalance(key: EndpointAPIKey, options: { silent?: boolean } = {}) {
if (!props.providerId || refreshingBalanceKeyId.value || !canRefreshKeyBalance(key)) return
const architectureId = normalizeBalanceArchitectureId(key.upstream_metadata?.balance_query?.architecture_id)
if (!architectureId) return
refreshingBalanceKeyId.value = key.id
try {
const query: ProviderKeyBalanceQuery = {
key_id: key.id,
auth_type: key.auth_type === 'bearer' ? 'bearer' : 'api_key',
api_formats: key.api_formats || [],
architecture_id: architectureId,
save_result: true,
}
assignSavedBalanceQueryConfig(query, key)
const result = await queryProviderKeyBalance(props.providerId, query)
if (result.status !== 'success') {
if (!options.silent) {
showError(result.message || '余额刷新失败', '错误')
}
return
}
if (!options.silent) {
showSuccess('余额已刷新')
}
await loadProviderKeysPage(currentKeyPage.value)
emit('refresh')
} catch (err: unknown) {
if (!options.silent) {
showError(parseApiError(err, '余额刷新失败'), '错误')
}
} finally {
refreshingBalanceKeyId.value = null
}
}
function handleKeyPermissions(key: EndpointAPIKey) {
editingKey.value = key
keyPermissionsDialogOpen.value = true
@@ -2698,7 +2936,7 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
}
async function handleKeyChanged() {
await Promise.all([loadEndpoints(), loadMappingPreview()])
await Promise.all([loadEndpoints(), loadProviderKeysPage(currentKeyPage.value), loadMappingPreview()])
emit('refresh')
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
@@ -3169,6 +3407,58 @@ function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
return hasAntigravityQuotaData(key.upstream_metadata)
}
function getKeyBalanceSummary(key: EndpointAPIKey): KeyBalanceSummary | null {
const metadata = key.upstream_metadata?.balance_query
if (!metadata) return null
const updatedAt = toFiniteNumber(metadata.updated_at)
const available = toFiniteNumber(metadata.total_available)
const used = toFiniteNumber(metadata.total_used)
const granted = toFiniteNumber(metadata.total_granted)
if (updatedAt === null || (available === null && used === null && granted === null)) {
return null
}
const architectureId = String(metadata.architecture_id || '').trim()
const labels: Record<string, string> = {
new_api: 'NewAPI',
sub2api: 'Sub2API',
generic_api: '自定义'
}
return {
available,
used,
granted,
currency: String(metadata.currency || 'USD').trim() || 'USD',
updatedAt,
templateLabel: labels[architectureId] || architectureId || '余额查询',
architectureId,
planName: typeof metadata.plan_name === 'string' && metadata.plan_name.trim()
? metadata.plan_name.trim()
: null,
}
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function formatKeyBalanceAmount(value: unknown, currency = 'USD'): string {
const numberValue = toFiniteNumber(value)
if (numberValue === null) return '未知'
const normalizedCurrency = (currency || 'USD').toUpperCase()
const prefix = normalizedCurrency === 'USD'
? '$'
: normalizedCurrency === 'CNY'
? '¥'
: `${normalizedCurrency} `
const decimals = Math.abs(numberValue) >= 100 ? 2 : 4
return `${prefix}${numberValue.toFixed(decimals)}`
}
function formatUpdatedAt(updatedAt: number): string {
if (!updatedAt || typeof updatedAt !== 'number') return ''
const now = Math.floor(Date.now() / 1000)
@@ -3685,6 +3975,7 @@ async function loadProviderKeysPage(page = currentKeyPage.value) {
currentKeyPage.value = Math.min(result.page, nextTotalPages)
keyPageSize.value = result.page_size
syncCurrentSelections(endpoints.value, result.keys)
void refreshDueKeyBalances()
} catch (err: unknown) {
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
providerKeys.value = []
@@ -3782,6 +4073,10 @@ useEscapeKey(() => {
disableOnInput: true,
once: false
})
onUnmounted(() => {
stopKeyBalanceAutoRefreshTimer()
})
</script>
<style scoped>

View File

@@ -93,7 +93,7 @@
variant="ghost"
size="icon"
class="h-7 w-7"
title="扩展操作配置"
title="配置用量查询"
@click="$emit('openOpsConfig', provider)"
>
<KeyRound class="h-3.5 w-3.5" />
@@ -125,17 +125,9 @@
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
<!-- 余额加载中 -->
<span
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="text-muted-foreground flex items-center gap-1"
>
<Loader2 class="h-3 w-3 animate-spin" />
加载中...
</span>
<!-- 余额从上游 API 查询 -->
<span
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
v-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-muted-foreground"
>
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
@@ -157,6 +149,29 @@
:title="getProviderCheckin(provider.id)?.message"
>签到失败</span>
</span>
<!-- 保存到 Key 的手动余额查询摘要 -->
<span
v-else-if="getSavedKeyBalance(provider)"
class="text-muted-foreground inline-flex items-center gap-1"
:title="getSavedKeyBalanceTitle(provider)"
>
<WalletCards class="h-3 w-3 text-primary" />
余额
<span class="font-semibold text-foreground/90">
{{ formatKeyBalanceAmount(getSavedKeyBalance(provider)?.total_available, getSavedKeyBalance(provider)?.currency || 'USD') }}
</span>
<span class="text-muted-foreground/70">
{{ keyBalanceTemplateLabel(getSavedKeyBalance(provider)?.architecture_id) }} · {{ formatKeyBalanceUpdatedAt(getSavedKeyBalance(provider)?.updated_at) }}
</span>
</span>
<!-- 余额加载中 -->
<span
v-else-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="text-muted-foreground flex items-center gap-1"
>
<Loader2 class="h-3 w-3 animate-spin" />
加载中...
</span>
<!-- 余额查询失败时显示错误 -->
<span
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
@@ -233,13 +248,20 @@ import {
Check,
X,
Loader2,
WalletCards,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
import { type ProviderKeyBalanceSummary, type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
import {
formatKeyBalanceAmount,
formatKeyBalanceUpdatedAt,
hasKeyBalanceSummary,
keyBalanceTemplateLabel,
} from '@/features/providers/utils/keyBalanceSummary'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -307,4 +329,19 @@ function handleDescriptionKeydown(event: KeyboardEvent) {
function getCredentialLabel(provider: ProviderWithEndpointsSummary): '账号' | '密钥' {
return isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号'
}
function getSavedKeyBalance(provider: ProviderWithEndpointsSummary): ProviderKeyBalanceSummary | null {
return hasKeyBalanceSummary(provider.key_balance_summary) ? provider.key_balance_summary : null
}
function getSavedKeyBalanceTitle(provider: ProviderWithEndpointsSummary): string {
const summary = getSavedKeyBalance(provider)
if (!summary) return ''
const parts = [
summary.key_name ? `Key: ${summary.key_name}` : null,
keyBalanceTemplateLabel(summary.architecture_id),
formatKeyBalanceUpdatedAt(summary.updated_at),
].filter(Boolean)
return parts.join(' · ')
}
</script>

View File

@@ -163,7 +163,7 @@
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
title="扩展操作配置"
title="配置用量查询"
@click="$emit('openOpsConfig', provider)"
>
<KeyRound class="h-3.5 w-3.5" />

View File

@@ -0,0 +1,54 @@
import type { ProviderKeyBalanceSummary } from '@/api/endpoints'
export function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
export function hasKeyBalanceSummary(summary: ProviderKeyBalanceSummary | null | undefined): summary is ProviderKeyBalanceSummary {
if (!summary) return false
const updatedAt = toFiniteNumber(summary.updated_at)
if (updatedAt === null) return false
return toFiniteNumber(summary.total_available) !== null
|| toFiniteNumber(summary.total_used) !== null
|| toFiniteNumber(summary.total_granted) !== null
}
export function formatKeyBalanceAmount(value: unknown, currency = 'USD'): string {
const numberValue = toFiniteNumber(value)
if (numberValue === null) return '未知'
const normalizedCurrency = (currency || 'USD').toUpperCase()
const prefix = normalizedCurrency === 'USD'
? '$'
: normalizedCurrency === 'CNY'
? '¥'
: `${normalizedCurrency} `
const decimals = Math.abs(numberValue) >= 100 ? 2 : 4
return `${prefix}${numberValue.toFixed(decimals)}`
}
export function keyBalanceTemplateLabel(architectureId: unknown): string {
const normalized = String(architectureId || '').trim().toLowerCase().replace(/-/g, '_')
if (normalized === 'newapi' || normalized === 'new_api') return 'NewAPI'
if (normalized === 'sub2api') return 'Sub2API'
if (normalized === 'generic' || normalized === 'custom' || normalized === 'generic_api') return '自定义'
return normalized || '余额查询'
}
export function formatKeyBalanceUpdatedAt(updatedAt: unknown): string {
const timestamp = toFiniteNumber(updatedAt)
if (timestamp === null || timestamp <= 0) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - timestamp
if (diff <= 60) return '刚刚更新'
const minutes = Math.floor(diff / 60)
if (minutes < 60) return `${minutes}分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
return `${days}天前`
}

View File

@@ -295,6 +295,7 @@
<ProviderAuthDialog
v-model:open="opsConfigDialogOpen"
:provider-id="opsConfigProviderId"
:provider-name="opsConfigProviderName"
:provider-website="opsConfigProviderWebsite"
@saved="handleOpsConfigSaved"
/>
@@ -325,6 +326,7 @@ import { useProviderBalance } from '@/features/providers/composables/useProvider
import {
getProvidersSummary,
getProvider,
getProviderEndpoints,
deleteProvider,
getProviderDeleteTask,
updateProvider,
@@ -517,6 +519,7 @@ const {
// 扩展操作配置对话框
const opsConfigDialogOpen = ref(false)
const opsConfigProviderId = ref('')
const opsConfigProviderName = ref('')
const opsConfigProviderWebsite = ref('')
// 内联编辑备注
@@ -707,10 +710,21 @@ async function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
}
// 打开扩展操作配置对话框
function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
async function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
opsConfigProviderId.value = provider.id
opsConfigProviderName.value = provider.name
opsConfigProviderWebsite.value = provider.website || ''
opsConfigDialogOpen.value = true
if (!opsConfigProviderWebsite.value) {
try {
const endpoints = await getProviderEndpoints(provider.id)
if (opsConfigProviderId.value !== provider.id || opsConfigProviderWebsite.value) return
const endpoint = endpoints.find(item => item.is_active) || endpoints[0]
opsConfigProviderWebsite.value = endpoint?.base_url || ''
} catch {
// 保持空地址,弹窗内仍可手动填写。
}
}
}
// 扩展操作配置保存回调