mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
54
frontend/src/features/providers/utils/keyBalanceSummary.ts
Normal file
54
frontend/src/features/providers/utils/keyBalanceSummary.ts
Normal 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}天前`
|
||||
}
|
||||
@@ -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 {
|
||||
// 保持空地址,弹窗内仍可手动填写。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 扩展操作配置保存回调
|
||||
|
||||
Reference in New Issue
Block a user