feat(admin): 新增账户余额/配额查询与展示

- 新增 accountBalanceService 与多 Provider 适配(Claude/Claude Console/OpenAI Responses/通用)
  - Redis 增加余额查询结果与本地统计缓存读写
  - 管理端新增 /admin/accounts/balance 相关接口与汇总接口,并在应用启动时注册 Provider
  - 后台前端新增余额组件与 Dashboard 余额/配额汇总、低余额/高使用提示
  - 补充 accountBalanceService 单元测试
This commit is contained in:
atoz03
2025-12-12 22:53:05 +08:00
parent 5863816882
commit f6ed420401
15 changed files with 1934 additions and 1 deletions

View File

@@ -0,0 +1,261 @@
<template>
<div class="min-w-[200px] space-y-1">
<div v-if="loading" class="flex items-center gap-2">
<i class="fas fa-spinner fa-spin text-gray-400 dark:text-gray-500"></i>
<span class="text-xs text-gray-500 dark:text-gray-400">加载中...</span>
</div>
<div v-else-if="requestError" class="flex items-center gap-2">
<i class="fas fa-exclamation-circle text-red-500"></i>
<span class="text-xs text-red-600 dark:text-red-400">{{ requestError }}</span>
<button
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
:disabled="refreshing"
@click="reload"
>
重试
</button>
</div>
<div v-else-if="balanceData" class="space-y-1">
<div v-if="balanceData.status === 'error' && balanceData.error" class="text-xs text-red-500">
{{ balanceData.error }}
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i
class="fas"
:class="
balanceData.balance
? 'fa-wallet text-green-600 dark:text-green-400'
: 'fa-chart-line text-gray-500 dark:text-gray-400'
"
></i>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ primaryText }}
</span>
<span class="rounded px-1.5 py-0.5 text-xs" :class="sourceClass">
{{ sourceLabel }}
</span>
</div>
<button
v-if="!hideRefresh"
class="text-xs text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
:disabled="refreshing"
:title="refreshing ? '刷新中...' : '刷新余额'"
@click="refresh"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
</button>
</div>
<!-- 配额如适用 -->
<div v-if="quotaInfo" class="space-y-1">
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>已用: {{ formatNumber(quotaInfo.used) }}</span>
<span>剩余: {{ formatNumber(quotaInfo.remaining) }}</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-1.5 rounded-full transition-all"
:class="quotaBarClass"
:style="{ width: `${Math.min(100, quotaInfo.percentage)}%` }"
></div>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">
{{ quotaInfo.percentage.toFixed(1) }}% 已使用
</span>
<span v-if="quotaInfo.resetAt" class="text-gray-400 dark:text-gray-500">
重置: {{ formatResetTime(quotaInfo.resetAt) }}
</span>
</div>
</div>
<div v-else-if="balanceData.quota?.unlimited" class="flex items-center gap-2">
<i class="fas fa-infinity text-blue-500 dark:text-blue-400"></i>
<span class="text-xs text-gray-600 dark:text-gray-400">无限制</span>
</div>
<div
v-if="balanceData.cacheExpiresAt && balanceData.source === 'cache'"
class="text-xs text-gray-400 dark:text-gray-500"
>
缓存至: {{ formatCacheExpiry(balanceData.cacheExpiresAt) }}
</div>
</div>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">暂无余额数据</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { apiClient } from '@/config/api'
const props = defineProps({
accountId: { type: String, required: true },
platform: { type: String, required: true },
initialBalance: { type: Object, default: null },
hideRefresh: { type: Boolean, default: false },
autoLoad: { type: Boolean, default: true }
})
const emit = defineEmits(['refreshed', 'error'])
const balanceData = ref(props.initialBalance)
const loading = ref(false)
const refreshing = ref(false)
const requestError = ref(null)
const sourceClass = computed(() => {
const source = balanceData.value?.source
return {
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300': source === 'api',
'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300': source === 'cache',
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': source === 'local'
}
})
const sourceLabel = computed(() => {
const source = balanceData.value?.source
return { api: 'API', cache: '缓存', local: '本地' }[source] || '未知'
})
const quotaInfo = computed(() => {
const quota = balanceData.value?.quota
if (!quota || quota.unlimited) return null
if (typeof quota.percentage !== 'number' || !Number.isFinite(quota.percentage)) return null
return {
used: quota.used ?? 0,
remaining: quota.remaining ?? 0,
percentage: quota.percentage,
resetAt: quota.resetAt || null
}
})
const quotaBarClass = computed(() => {
const percentage = quotaInfo.value?.percentage || 0
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
if (percentage >= 70) return 'bg-yellow-500 dark:bg-yellow-600'
return 'bg-green-500 dark:bg-green-600'
})
const primaryText = computed(() => {
if (balanceData.value?.balance?.formattedAmount) {
return balanceData.value.balance.formattedAmount
}
const dailyCost = Number(balanceData.value?.statistics?.dailyCost || 0)
return `今日成本 ${formatCurrency(dailyCost)}`
})
const load = async () => {
if (!props.autoLoad) return
if (!props.accountId || !props.platform) return
loading.value = true
requestError.value = null
try {
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
params: { platform: props.platform, queryApi: false }
})
if (response?.success) {
balanceData.value = response.data
} else {
requestError.value = response?.error || '加载失败'
}
} catch (error) {
requestError.value = error.message || '网络错误'
emit('error', error)
} finally {
loading.value = false
}
}
const refresh = async () => {
if (!props.accountId || !props.platform) return
if (refreshing.value) return
refreshing.value = true
requestError.value = null
try {
const response = await apiClient.post(`/admin/accounts/${props.accountId}/balance/refresh`, {
platform: props.platform
})
if (response?.success) {
balanceData.value = response.data
emit('refreshed', response.data)
} else {
requestError.value = response?.error || '刷新失败'
}
} catch (error) {
requestError.value = error.message || '网络错误'
emit('error', error)
} finally {
refreshing.value = false
}
}
const reload = async () => {
await load()
}
const formatNumber = (num) => {
if (num === Infinity) return '∞'
const value = Number(num)
if (!Number.isFinite(value)) return 'N/A'
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
const formatCurrency = (amount) => {
const value = Number(amount)
if (!Number.isFinite(value)) return '$0.00'
if (value >= 1) return `$${value.toFixed(2)}`
if (value >= 0.01) return `$${value.toFixed(3)}`
return `$${value.toFixed(6)}`
}
const formatResetTime = (isoString) => {
const date = new Date(isoString)
const now = new Date()
const diff = date.getTime() - now.getTime()
if (!Number.isFinite(diff)) return '未知'
if (diff < 0) return '已过期'
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(minutes / 60)
const remainMinutes = minutes % 60
if (hours >= 24) {
const days = Math.floor(hours / 24)
return `${days}天后`
}
return `${hours}小时${remainMinutes}分钟`
}
const formatCacheExpiry = (isoString) => {
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) return '未知'
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
watch(
() => props.initialBalance,
(newVal) => {
if (newVal) {
balanceData.value = newVal
}
}
)
onMounted(() => {
if (!props.initialBalance) {
load()
}
})
defineExpose({ refresh, reload })
</script>

View File

@@ -141,6 +141,32 @@
</el-tooltip>
</div>
<!-- 刷新余额按钮 -->
<div class="relative">
<el-tooltip
content="刷新当前页余额(触发查询,失败自动降级)"
effect="dark"
placement="bottom"
>
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
:disabled="accountsLoading || refreshingBalances"
@click="refreshVisibleBalances"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i
:class="[
'fas relative text-blue-500',
refreshingBalances ? 'fa-spinner fa-spin' : 'fa-wallet'
]"
/>
<span class="relative">刷新余额</span>
</button>
</el-tooltip>
</div>
<!-- 选择/取消选择按钮 -->
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@@ -263,6 +289,11 @@
>
今日使用
</th>
<th
class="min-w-[220px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
余额/配额
</th>
<th
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
@@ -765,6 +796,15 @@
</div>
<div v-else class="text-xs text-gray-400">暂无数据</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<BalanceDisplay
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div v-if="account.platform === 'claude'" class="space-y-2">
<!-- OAuth 账户:显示三窗口 OAuth usage -->
@@ -1425,6 +1465,18 @@
</div>
</div>
<!-- 余额/配额 -->
<div class="mb-3">
<p class="mb-1 text-xs text-gray-500 dark:text-gray-400">余额/配额</p>
<BalanceDisplay
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
</div>
<!-- 状态信息 -->
<div class="mb-3 space-y-2">
<!-- 会话窗口 -->
@@ -2062,6 +2114,7 @@ import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTes
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue'
import ActionDropdown from '@/components/common/ActionDropdown.vue'
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
// 使用确认弹窗
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
@@ -2069,6 +2122,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
// 数据状态
const accounts = ref([])
const accountsLoading = ref(false)
const refreshingBalances = ref(false)
const accountsSortBy = ref('name')
const accountsSortOrder = ref('asc')
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
@@ -2768,6 +2822,72 @@ const paginatedAccounts = computed(() => {
return sortedAccounts.value.slice(start, end)
})
// 余额刷新成功回调
const handleBalanceRefreshed = (accountId, balanceInfo) => {
accounts.value = accounts.value.map((account) => {
if (account.id !== accountId) return account
return { ...account, balanceInfo }
})
}
// 余额请求错误回调(仅提示,不中断页面)
const handleBalanceError = (_accountId, error) => {
const message = error?.message || '余额查询失败'
showToast(message, 'error')
}
// 批量刷新当前页余额(触发查询)
const refreshVisibleBalances = async () => {
if (refreshingBalances.value) return
const targets = paginatedAccounts.value
if (!targets || targets.length === 0) {
return
}
refreshingBalances.value = true
try {
const results = await Promise.all(
targets.map(async (account) => {
try {
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
platform: account.platform
})
return { id: account.id, success: !!response?.success, data: response?.data || null }
} catch (error) {
return { id: account.id, success: false, error: error?.message || '刷新失败' }
}
})
)
const updatedMap = results.reduce((map, item) => {
if (item.success && item.data) {
map[item.id] = item.data
}
return map
}, {})
const successCount = results.filter((r) => r.success).length
const failCount = results.length - successCount
if (Object.keys(updatedMap).length > 0) {
accounts.value = accounts.value.map((account) => {
const balanceInfo = updatedMap[account.id]
if (!balanceInfo) return account
return { ...account, balanceInfo }
})
}
if (failCount === 0) {
showToast(`成功刷新 ${successCount} 个账户余额`, 'success')
} else {
showToast(`刷新完成:${successCount} 成功,${failCount} 失败`, 'warning')
}
} finally {
refreshingBalances.value = false
}
}
const updateSelectAllState = () => {
const currentIds = paginatedAccounts.value.map((account) => account.id)
const selectedInCurrentPage = currentIds.filter((id) =>
@@ -2818,6 +2938,54 @@ const cleanupSelectedAccounts = () => {
updateSelectAllState()
}
// 异步加载余额缓存(按平台批量拉取,避免逐行请求)
const loadBalanceCacheForAccounts = async () => {
const current = accounts.value
if (!Array.isArray(current) || current.length === 0) {
return
}
const platforms = Array.from(new Set(current.map((acc) => acc.platform).filter(Boolean)))
if (platforms.length === 0) {
return
}
const responses = await Promise.all(
platforms.map(async (platform) => {
try {
const res = await apiClient.get(`/admin/accounts/balance/platform/${platform}`, {
params: { queryApi: false }
})
return { platform, success: !!res?.success, data: res?.data || [] }
} catch (error) {
console.debug(`Failed to load balance cache for ${platform}:`, error)
return { platform, success: false, data: [] }
}
})
)
const balanceMap = responses.reduce((map, item) => {
if (!item.success) return map
const list = Array.isArray(item.data) ? item.data : []
list.forEach((entry) => {
const accountId = entry?.data?.accountId
if (accountId) {
map[accountId] = entry.data
}
})
return map
}, {})
if (Object.keys(balanceMap).length === 0) {
return
}
accounts.value = accounts.value.map((account) => ({
...account,
balanceInfo: balanceMap[account.id] || account.balanceInfo || null
}))
}
// 加载账户列表
const loadAccounts = async (forceReload = false) => {
accountsLoading.value = true
@@ -3010,6 +3178,11 @@ const loadAccounts = async (forceReload = false) => {
console.debug('Claude usage loading failed:', err)
})
}
// 异步加载余额缓存(按平台批量)
loadBalanceCacheForAccounts().catch((err) => {
console.debug('Balance cache loading failed:', err)
})
} catch (error) {
showToast('加载账户失败', 'error')
} finally {

View File

@@ -196,6 +196,105 @@
</div>
</div>
<!-- 账户余额/配额汇总 -->
<div class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6">
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
账户余额/配额
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ formatCurrencyUsd(balanceSummary.totalBalance || 0) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
低余额: {{ balanceSummary.lowBalanceCount || 0 }} | 总成本:
{{ formatCurrencyUsd(balanceSummary.totalCost || 0) }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-green-600">
<i class="fas fa-wallet" />
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
更新时间: {{ formatLastUpdate(balanceSummaryUpdatedAt) }}
</p>
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500"
:disabled="loadingBalanceSummary"
@click="loadBalanceSummary"
>
<i :class="['fas', loadingBalanceSummary ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
刷新
</button>
</div>
</div>
<div class="card p-4 sm:p-6">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">低余额账户</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ lowBalanceAccounts.length }} 个
</span>
</div>
<div
v-if="loadingBalanceSummary"
class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
正在加载...
</div>
<div
v-else-if="lowBalanceAccounts.length === 0"
class="py-6 text-center text-sm text-green-600 dark:text-green-400"
>
全部正常
</div>
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
<div
v-for="account in lowBalanceAccounts"
:key="account.accountId"
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900/60 dark:bg-red-900/20"
>
<div class="flex items-center justify-between gap-2">
<div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{{ account.name || account.accountId }}
</div>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{{ getBalancePlatformLabel(account.platform) }}
</span>
</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
<span v-if="account.balance">余额: {{ account.balance.formattedAmount }}</span>
<span v-else
>今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }}</span
>
</div>
<div v-if="account.quota && typeof account.quota.percentage === 'number'" class="mt-2">
<div
class="mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"
>
<span>配额使用</span>
<span class="text-red-600 dark:text-red-400">
{{ account.quota.percentage.toFixed(1) }}%
</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-red-500"
:style="{ width: `${Math.min(100, account.quota.percentage)}%` }"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Token统计和性能指标 -->
<div
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
@@ -681,6 +780,8 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useDashboardStore } from '@/stores/dashboard'
import { useThemeStore } from '@/stores/theme'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import Chart from 'chart.js/auto'
const dashboardStore = useDashboardStore()
@@ -732,6 +833,97 @@ const accountGroupOptions = [
const accountTrendUpdating = ref(false)
// 余额/配额汇总
const balanceSummary = ref({
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
})
const loadingBalanceSummary = ref(false)
const balanceSummaryUpdatedAt = ref(null)
const getBalancePlatformLabel = (platform) => {
const map = {
claude: 'Claude',
'claude-console': 'Claude Console',
gemini: 'Gemini',
'gemini-api': 'Gemini API',
openai: 'OpenAI',
'openai-responses': 'OpenAI Responses',
azure_openai: 'Azure OpenAI',
bedrock: 'Bedrock',
droid: 'Droid',
ccr: 'CCR'
}
return map[platform] || platform
}
const lowBalanceAccounts = computed(() => {
const result = []
const platforms = balanceSummary.value?.platforms || {}
Object.entries(platforms).forEach(([platform, data]) => {
const list = Array.isArray(data?.accounts) ? data.accounts : []
list.forEach((entry) => {
const accountData = entry?.data
if (!accountData) return
const amount = accountData.balance?.amount
const percentage = accountData.quota?.percentage
const isLowBalance = typeof amount === 'number' && amount < 10
const isHighUsage = typeof percentage === 'number' && percentage > 90
if (isLowBalance || isHighUsage) {
result.push({
...accountData,
name: entry?.name || accountData.accountId,
platform: accountData.platform || platform
})
}
})
})
return result
})
const formatCurrencyUsd = (amount) => {
const value = Number(amount)
if (!Number.isFinite(value)) return '$0.00'
if (value >= 1) return `$${value.toFixed(2)}`
if (value >= 0.01) return `$${value.toFixed(3)}`
return `$${value.toFixed(6)}`
}
const formatLastUpdate = (isoString) => {
if (!isoString) return '未知'
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) return '未知'
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
const loadBalanceSummary = async () => {
loadingBalanceSummary.value = true
try {
const response = await apiClient.get('/admin/accounts/balance/summary')
if (response?.success) {
balanceSummary.value = response.data || {
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
}
balanceSummaryUpdatedAt.value = new Date().toISOString()
}
} catch (error) {
console.debug('加载余额汇总失败:', error)
showToast('加载余额汇总失败', 'error')
} finally {
loadingBalanceSummary.value = false
}
}
// 自动刷新相关
const autoRefreshEnabled = ref(false)
const autoRefreshInterval = ref(30) // 秒
@@ -1488,7 +1680,7 @@ async function refreshAllData() {
isRefreshing.value = true
try {
await Promise.all([loadDashboardData(), refreshChartsData()])
await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()])
} finally {
isRefreshing.value = false
}