mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat(admin): 新增账户余额/配额查询与展示
- 新增 accountBalanceService 与多 Provider 适配(Claude/Claude Console/OpenAI Responses/通用) - Redis 增加余额查询结果与本地统计缓存读写 - 管理端新增 /admin/accounts/balance 相关接口与汇总接口,并在应用启动时注册 Provider - 后台前端新增余额组件与 Dashboard 余额/配额汇总、低余额/高使用提示 - 补充 accountBalanceService 单元测试
This commit is contained in:
10
src/app.js
10
src/app.js
@@ -52,6 +52,16 @@ class Application {
|
|||||||
await redis.connect()
|
await redis.connect()
|
||||||
logger.success('✅ Redis connected successfully')
|
logger.success('✅ Redis connected successfully')
|
||||||
|
|
||||||
|
// 💳 初始化账户余额查询服务(Provider 注册)
|
||||||
|
try {
|
||||||
|
const accountBalanceService = require('./services/accountBalanceService')
|
||||||
|
const { registerAllProviders } = require('./services/balanceProviders')
|
||||||
|
registerAllProviders(accountBalanceService)
|
||||||
|
logger.info('✅ 账户余额查询服务已初始化')
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
// 💰 初始化价格服务
|
// 💰 初始化价格服务
|
||||||
logger.info('🔄 Initializing pricing service...')
|
logger.info('🔄 Initializing pricing service...')
|
||||||
await pricingService.initialize()
|
await pricingService.initialize()
|
||||||
|
|||||||
@@ -1521,6 +1521,99 @@ class RedisClient {
|
|||||||
return await this.client.del(key)
|
return await this.client.del(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 💰 账户余额缓存(API 查询结果)
|
||||||
|
async setAccountBalance(platform, accountId, balanceData, ttl = 3600) {
|
||||||
|
const key = `account_balance:${platform}:${accountId}`
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
balance:
|
||||||
|
balanceData && balanceData.balance !== null && balanceData.balance !== undefined
|
||||||
|
? String(balanceData.balance)
|
||||||
|
: '',
|
||||||
|
currency: balanceData?.currency || 'USD',
|
||||||
|
lastRefreshAt: balanceData?.lastRefreshAt || new Date().toISOString(),
|
||||||
|
queryMethod: balanceData?.queryMethod || 'api',
|
||||||
|
status: balanceData?.status || 'success',
|
||||||
|
errorMessage: balanceData?.errorMessage || balanceData?.error || '',
|
||||||
|
rawData: balanceData?.rawData ? JSON.stringify(balanceData.rawData) : '',
|
||||||
|
quota: balanceData?.quota ? JSON.stringify(balanceData.quota) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.client.hset(key, payload)
|
||||||
|
await this.client.expire(key, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountBalance(platform, accountId) {
|
||||||
|
const key = `account_balance:${platform}:${accountId}`
|
||||||
|
const [data, ttlSeconds] = await Promise.all([this.client.hgetall(key), this.client.ttl(key)])
|
||||||
|
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawData = null
|
||||||
|
if (data.rawData) {
|
||||||
|
try {
|
||||||
|
rawData = JSON.parse(data.rawData)
|
||||||
|
} catch (error) {
|
||||||
|
rawData = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let quota = null
|
||||||
|
if (data.quota) {
|
||||||
|
try {
|
||||||
|
quota = JSON.parse(data.quota)
|
||||||
|
} catch (error) {
|
||||||
|
quota = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: data.balance ? parseFloat(data.balance) : null,
|
||||||
|
currency: data.currency || 'USD',
|
||||||
|
lastRefreshAt: data.lastRefreshAt || null,
|
||||||
|
queryMethod: data.queryMethod || null,
|
||||||
|
status: data.status || null,
|
||||||
|
errorMessage: data.errorMessage || '',
|
||||||
|
rawData,
|
||||||
|
quota,
|
||||||
|
ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 账户余额缓存(本地统计)
|
||||||
|
async setLocalBalance(platform, accountId, statisticsData, ttl = 300) {
|
||||||
|
const key = `account_balance_local:${platform}:${accountId}`
|
||||||
|
|
||||||
|
await this.client.hset(key, {
|
||||||
|
estimatedBalance: JSON.stringify(statisticsData || {}),
|
||||||
|
lastCalculated: new Date().toISOString()
|
||||||
|
})
|
||||||
|
await this.client.expire(key, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocalBalance(platform, accountId) {
|
||||||
|
const key = `account_balance_local:${platform}:${accountId}`
|
||||||
|
const data = await this.client.hgetall(key)
|
||||||
|
|
||||||
|
if (!data || !data.estimatedBalance) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data.estimatedBalance)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccountBalance(platform, accountId) {
|
||||||
|
const key = `account_balance:${platform}:${accountId}`
|
||||||
|
const localKey = `account_balance_local:${platform}:${accountId}`
|
||||||
|
await this.client.del(key, localKey)
|
||||||
|
}
|
||||||
|
|
||||||
// 📈 系统统计
|
// 📈 系统统计
|
||||||
async getSystemStats() {
|
async getSystemStats() {
|
||||||
const keys = await Promise.all([
|
const keys = await Promise.all([
|
||||||
|
|||||||
130
src/routes/admin/accountBalance.js
Normal file
130
src/routes/admin/accountBalance.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const accountBalanceService = require('../../services/accountBalanceService')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const ensureValidPlatform = (rawPlatform) => {
|
||||||
|
const normalized = accountBalanceService.normalizePlatform(rawPlatform)
|
||||||
|
if (!normalized) {
|
||||||
|
return { ok: false, status: 400, error: '缺少 platform 参数' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = accountBalanceService.getSupportedPlatforms()
|
||||||
|
if (!supported.includes(normalized)) {
|
||||||
|
return { ok: false, status: 400, error: `不支持的平台: ${normalized}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, platform: normalized }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 获取账户余额(默认本地统计优先,可选触发 Provider)
|
||||||
|
// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false
|
||||||
|
router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform, queryApi } = req.query
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, {
|
||||||
|
queryApi
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!balance) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(balance)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取账户余额失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) 强制刷新账户余额(触发 Provider)
|
||||||
|
// POST /admin/accounts/:accountId/balance/refresh
|
||||||
|
// Body: { platform: 'xxx' }
|
||||||
|
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform } = req.body || {}
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`手动刷新余额: ${valid.platform}:${accountId}`)
|
||||||
|
|
||||||
|
const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform)
|
||||||
|
if (!balance) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(balance)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('刷新账户余额失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3) 批量获取平台所有账户余额
|
||||||
|
// GET /admin/accounts/balance/platform/:platform?queryApi=false
|
||||||
|
router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { platform } = req.params
|
||||||
|
const { queryApi } = req.query
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi })
|
||||||
|
|
||||||
|
return res.json({ success: true, data: balances })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('批量获取余额失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4) 获取余额汇总(Dashboard 用)
|
||||||
|
// GET /admin/accounts/balance/summary
|
||||||
|
router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const summary = await accountBalanceService.getBalanceSummary()
|
||||||
|
return res.json({ success: true, data: summary })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取余额汇总失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5) 清除缓存
|
||||||
|
// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx
|
||||||
|
router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { platform } = req.query
|
||||||
|
|
||||||
|
const valid = ensureValidPlatform(platform)
|
||||||
|
if (!valid.ok) {
|
||||||
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
await accountBalanceService.clearCache(accountId, valid.platform)
|
||||||
|
|
||||||
|
return res.json({ success: true, message: '缓存已清除' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('清除缓存失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -21,6 +21,7 @@ const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts')
|
|||||||
const droidAccountsRoutes = require('./droidAccounts')
|
const droidAccountsRoutes = require('./droidAccounts')
|
||||||
const dashboardRoutes = require('./dashboard')
|
const dashboardRoutes = require('./dashboard')
|
||||||
const usageStatsRoutes = require('./usageStats')
|
const usageStatsRoutes = require('./usageStats')
|
||||||
|
const accountBalanceRoutes = require('./accountBalance')
|
||||||
const systemRoutes = require('./system')
|
const systemRoutes = require('./system')
|
||||||
const concurrencyRoutes = require('./concurrency')
|
const concurrencyRoutes = require('./concurrency')
|
||||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||||
@@ -36,6 +37,7 @@ router.use('/', openaiResponsesAccountsRoutes)
|
|||||||
router.use('/', droidAccountsRoutes)
|
router.use('/', droidAccountsRoutes)
|
||||||
router.use('/', dashboardRoutes)
|
router.use('/', dashboardRoutes)
|
||||||
router.use('/', usageStatsRoutes)
|
router.use('/', usageStatsRoutes)
|
||||||
|
router.use('/', accountBalanceRoutes)
|
||||||
router.use('/', systemRoutes)
|
router.use('/', systemRoutes)
|
||||||
router.use('/', concurrencyRoutes)
|
router.use('/', concurrencyRoutes)
|
||||||
router.use('/', claudeRelayConfigRoutes)
|
router.use('/', claudeRelayConfigRoutes)
|
||||||
|
|||||||
652
src/services/accountBalanceService.js
Normal file
652
src/services/accountBalanceService.js
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
|
|
||||||
|
class AccountBalanceService {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.redis = options.redis || redis
|
||||||
|
this.logger = options.logger || logger
|
||||||
|
|
||||||
|
this.providers = new Map()
|
||||||
|
|
||||||
|
this.CACHE_TTL_SECONDS = 3600
|
||||||
|
this.LOCAL_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
this.LOW_BALANCE_THRESHOLD = 10
|
||||||
|
this.HIGH_USAGE_THRESHOLD_PERCENT = 90
|
||||||
|
this.DEFAULT_CONCURRENCY = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedPlatforms() {
|
||||||
|
return [
|
||||||
|
'claude',
|
||||||
|
'claude-console',
|
||||||
|
'gemini',
|
||||||
|
'gemini-api',
|
||||||
|
'openai',
|
||||||
|
'openai-responses',
|
||||||
|
'azure_openai',
|
||||||
|
'bedrock',
|
||||||
|
'droid',
|
||||||
|
'ccr'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePlatform(platform) {
|
||||||
|
if (!platform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = String(platform).trim().toLowerCase()
|
||||||
|
|
||||||
|
// 兼容实施文档与历史命名
|
||||||
|
if (value === 'claude-official') {
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
if (value === 'azure-openai') {
|
||||||
|
return 'azure_openai'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持前端平台键一致
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProvider(platform, provider) {
|
||||||
|
const normalized = this.normalizePlatform(platform)
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('registerProvider: 缺少 platform')
|
||||||
|
}
|
||||||
|
if (!provider || typeof provider.queryBalance !== 'function') {
|
||||||
|
throw new Error(`registerProvider: Provider 无效 (${normalized})`)
|
||||||
|
}
|
||||||
|
this.providers.set(normalized, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountBalance(accountId, platform, options = {}) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await this._getAccountBalanceForAccount(account, normalizedPlatform, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccountBalance(accountId, platform) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const account = await this.getAccount(accountId, normalizedPlatform)
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this._getAccountBalanceForAccount(account, normalizedPlatform, {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAccountsBalance(platform, options = {}) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
const accounts = await this.getAllAccountsByPlatform(normalizedPlatform)
|
||||||
|
const queryApi = this._parseBoolean(options.queryApi) || false
|
||||||
|
const useCache = options.useCache !== false
|
||||||
|
|
||||||
|
const results = await this._mapWithConcurrency(
|
||||||
|
accounts,
|
||||||
|
this.DEFAULT_CONCURRENCY,
|
||||||
|
async (acc) => {
|
||||||
|
try {
|
||||||
|
const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, {
|
||||||
|
queryApi,
|
||||||
|
useCache
|
||||||
|
})
|
||||||
|
return { ...balance, name: acc.name || '' }
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId: acc?.id,
|
||||||
|
platform: normalizedPlatform,
|
||||||
|
balance: null,
|
||||||
|
quota: null,
|
||||||
|
statistics: {},
|
||||||
|
source: 'local',
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
cacheExpiresAt: null,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message || '批量查询失败'
|
||||||
|
},
|
||||||
|
name: acc?.name || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalanceSummary() {
|
||||||
|
const platforms = this.getSupportedPlatforms()
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
platforms: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const accounts = await this.getAllAccountsByPlatform(platform)
|
||||||
|
const platformData = {
|
||||||
|
count: accounts.length,
|
||||||
|
totalBalance: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
lowBalanceCount: 0,
|
||||||
|
accounts: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await this._mapWithConcurrency(
|
||||||
|
accounts,
|
||||||
|
this.DEFAULT_CONCURRENCY,
|
||||||
|
async (acc) => {
|
||||||
|
const balance = await this._getAccountBalanceForAccount(acc, platform, {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
return { ...balance, name: acc.name || '' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const item of balances) {
|
||||||
|
platformData.accounts.push(item)
|
||||||
|
|
||||||
|
const amount = item?.data?.balance?.amount
|
||||||
|
const percentage = item?.data?.quota?.percentage
|
||||||
|
const totalCost = Number(item?.data?.statistics?.totalCost || 0)
|
||||||
|
|
||||||
|
const hasAmount = typeof amount === 'number' && Number.isFinite(amount)
|
||||||
|
const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD
|
||||||
|
const isHighUsage =
|
||||||
|
typeof percentage === 'number' &&
|
||||||
|
Number.isFinite(percentage) &&
|
||||||
|
percentage > this.HIGH_USAGE_THRESHOLD_PERCENT
|
||||||
|
|
||||||
|
if (hasAmount) {
|
||||||
|
platformData.totalBalance += amount
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLowBalance || isHighUsage) {
|
||||||
|
platformData.lowBalanceCount += 1
|
||||||
|
summary.lowBalanceCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
platformData.totalCost += totalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.platforms[platform] = platformData
|
||||||
|
summary.totalBalance += platformData.totalBalance
|
||||||
|
summary.totalCost += platformData.totalCost
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCache(accountId, platform) {
|
||||||
|
const normalizedPlatform = this.normalizePlatform(platform)
|
||||||
|
if (!normalizedPlatform) {
|
||||||
|
throw new Error('缺少 platform 参数')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.deleteAccountBalance(normalizedPlatform, accountId)
|
||||||
|
this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccount(accountId, platform) {
|
||||||
|
if (!accountId || !platform) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap = {
|
||||||
|
claude: require('./claudeAccountService'),
|
||||||
|
'claude-console': require('./claudeConsoleAccountService'),
|
||||||
|
gemini: require('./geminiAccountService'),
|
||||||
|
'gemini-api': require('./geminiApiAccountService'),
|
||||||
|
openai: require('./openaiAccountService'),
|
||||||
|
'openai-responses': require('./openaiResponsesAccountService'),
|
||||||
|
azure_openai: require('./azureOpenaiAccountService'),
|
||||||
|
bedrock: require('./bedrockAccountService'),
|
||||||
|
droid: require('./droidAccountService'),
|
||||||
|
ccr: require('./ccrAccountService')
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = serviceMap[platform]
|
||||||
|
if (!service || typeof service.getAccount !== 'function') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await service.getAccount(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAccountsByPlatform(platform) {
|
||||||
|
if (!platform) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap = {
|
||||||
|
claude: require('./claudeAccountService'),
|
||||||
|
'claude-console': require('./claudeConsoleAccountService'),
|
||||||
|
gemini: require('./geminiAccountService'),
|
||||||
|
'gemini-api': require('./geminiApiAccountService'),
|
||||||
|
openai: require('./openaiAccountService'),
|
||||||
|
'openai-responses': require('./openaiResponsesAccountService'),
|
||||||
|
azure_openai: require('./azureOpenaiAccountService'),
|
||||||
|
bedrock: require('./bedrockAccountService'),
|
||||||
|
droid: require('./droidAccountService'),
|
||||||
|
ccr: require('./ccrAccountService')
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = serviceMap[platform]
|
||||||
|
if (!service) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock 特殊:返回 { success, data }
|
||||||
|
if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') {
|
||||||
|
const result = await service.getAllAccounts()
|
||||||
|
return result?.success ? result.data || [] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'openai-responses') {
|
||||||
|
return await service.getAllAccounts(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof service.getAllAccounts !== 'function') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return await service.getAllAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getAccountBalanceForAccount(account, platform, options = {}) {
|
||||||
|
const queryApi = this._parseBoolean(options.queryApi) || false
|
||||||
|
const useCache = options.useCache !== false
|
||||||
|
|
||||||
|
const accountId = account?.id
|
||||||
|
if (!accountId) {
|
||||||
|
throw new Error('账户缺少 id')
|
||||||
|
}
|
||||||
|
|
||||||
|
const localBalance = await this._getBalanceFromLocal(accountId, platform)
|
||||||
|
const localStatistics = localBalance.statistics || {}
|
||||||
|
|
||||||
|
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
|
||||||
|
|
||||||
|
// 非强制查询:优先读缓存
|
||||||
|
if (!queryApi) {
|
||||||
|
if (useCache) {
|
||||||
|
const cached = await this.redis.getAccountBalance(platform, accountId)
|
||||||
|
if (cached && cached.status === 'success') {
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: cached.status,
|
||||||
|
errorMessage: cached.errorMessage,
|
||||||
|
balance: quotaFromLocal.balance ?? cached.balance,
|
||||||
|
currency: quotaFromLocal.currency || cached.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota || cached.quota || null,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: cached.lastRefreshAt
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'cache',
|
||||||
|
cached.ttlSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'success',
|
||||||
|
errorMessage: null,
|
||||||
|
balance: quotaFromLocal.balance,
|
||||||
|
currency: quotaFromLocal.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: localBalance.lastCalculated
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'local'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制查询:调用 Provider,失败自动降级到本地统计
|
||||||
|
const provider = this.providers.get(platform)
|
||||||
|
if (!provider) {
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: `不支持的平台: ${platform}`,
|
||||||
|
balance: quotaFromLocal.balance,
|
||||||
|
currency: quotaFromLocal.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'local'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerResult = await this._getBalanceFromProvider(provider, account)
|
||||||
|
await this.redis.setAccountBalance(platform, accountId, providerResult, this.CACHE_TTL_SECONDS)
|
||||||
|
|
||||||
|
const source = providerResult.status === 'success' ? 'api' : 'local'
|
||||||
|
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: providerResult.status,
|
||||||
|
errorMessage: providerResult.errorMessage,
|
||||||
|
balance: quotaFromLocal.balance ?? providerResult.balance,
|
||||||
|
currency: quotaFromLocal.currency || providerResult.currency || 'USD',
|
||||||
|
quota: quotaFromLocal.quota || providerResult.quota || null,
|
||||||
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: providerResult.lastRefreshAt
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromProvider(provider, account) {
|
||||||
|
try {
|
||||||
|
const result = await provider.queryBalance(account)
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
balance: typeof result?.balance === 'number' ? result.balance : null,
|
||||||
|
currency: result?.currency || 'USD',
|
||||||
|
quota: result?.quota || null,
|
||||||
|
queryMethod: result?.queryMethod || 'api',
|
||||||
|
rawData: result?.rawData || null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: error.message || '查询失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromLocal(accountId, platform) {
|
||||||
|
const cached = await this.redis.getLocalBalance(platform, accountId)
|
||||||
|
if (cached && cached.statistics) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const statistics = await this._computeLocalStatistics(accountId)
|
||||||
|
const localBalance = {
|
||||||
|
status: 'success',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
statistics,
|
||||||
|
queryMethod: 'local',
|
||||||
|
lastCalculated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS)
|
||||||
|
return localBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeLocalStatistics(accountId) {
|
||||||
|
const safeNumber = (value) => {
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usageStats = await this.redis.getAccountUsageStats(accountId)
|
||||||
|
const dailyCost = safeNumber(usageStats?.daily?.cost || 0)
|
||||||
|
const monthlyCost = await this._computeMonthlyCost(accountId)
|
||||||
|
const totalCost = await this._computeTotalCost(accountId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCost,
|
||||||
|
dailyCost,
|
||||||
|
monthlyCost,
|
||||||
|
totalRequests: safeNumber(usageStats?.total?.requests || 0),
|
||||||
|
dailyRequests: safeNumber(usageStats?.daily?.requests || 0),
|
||||||
|
monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`本地统计计算失败: ${accountId}`, error)
|
||||||
|
return {
|
||||||
|
totalCost: 0,
|
||||||
|
dailyCost: 0,
|
||||||
|
monthlyCost: 0,
|
||||||
|
totalRequests: 0,
|
||||||
|
dailyRequests: 0,
|
||||||
|
monthlyRequests: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeMonthlyCost(accountId) {
|
||||||
|
const tzDate = this.redis.getDateInTimezone(new Date())
|
||||||
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
||||||
|
2,
|
||||||
|
'0'
|
||||||
|
)}`
|
||||||
|
|
||||||
|
const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}`
|
||||||
|
return await this._sumModelCostsByKeysPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _computeTotalCost(accountId) {
|
||||||
|
const pattern = `account_usage:model:monthly:${accountId}:*:*`
|
||||||
|
return await this._sumModelCostsByKeysPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sumModelCostsByKeysPattern(pattern) {
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClientSafe()
|
||||||
|
const keys = await client.keys(pattern)
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
keys.forEach((key) => pipeline.hgetall(key))
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
let totalCost = 0
|
||||||
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
|
const [, data] = results[i] || []
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = String(keys[i]).split(':')
|
||||||
|
const model = parts[4] || 'unknown'
|
||||||
|
|
||||||
|
const usage = {
|
||||||
|
input_tokens: parseInt(data.inputTokens || 0),
|
||||||
|
output_tokens: parseInt(data.outputTokens || 0),
|
||||||
|
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
|
||||||
|
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const costResult = CostCalculator.calculateCost(usage, model)
|
||||||
|
totalCost += costResult.costs.total || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalCost
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`汇总模型费用失败: ${pattern}`, error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildQuotaFromLocal(account, statistics) {
|
||||||
|
if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return { balance: null, currency: null, quota: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyQuota = Number(account.dailyQuota || 0)
|
||||||
|
const used = Number(statistics?.dailyCost || 0)
|
||||||
|
|
||||||
|
const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00')
|
||||||
|
|
||||||
|
// 不限制
|
||||||
|
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: Infinity,
|
||||||
|
used,
|
||||||
|
remaining: Infinity,
|
||||||
|
percentage: 0,
|
||||||
|
unlimited: true,
|
||||||
|
resetAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = Math.max(0, dailyQuota - used)
|
||||||
|
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: remaining,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: dailyQuota,
|
||||||
|
used,
|
||||||
|
remaining,
|
||||||
|
resetAt,
|
||||||
|
percentage: Math.round(percentage * 100) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeNextResetAt(resetTime) {
|
||||||
|
const now = new Date()
|
||||||
|
const tzNow = this.redis.getDateInTimezone(now)
|
||||||
|
const offsetMs = tzNow.getTime() - now.getTime()
|
||||||
|
|
||||||
|
const [h, m] = String(resetTime || '00:00')
|
||||||
|
.split(':')
|
||||||
|
.map((n) => parseInt(n, 10))
|
||||||
|
|
||||||
|
const resetHour = Number.isFinite(h) ? h : 0
|
||||||
|
const resetMinute = Number.isFinite(m) ? m : 0
|
||||||
|
|
||||||
|
const year = tzNow.getUTCFullYear()
|
||||||
|
const month = tzNow.getUTCMonth()
|
||||||
|
const day = tzNow.getUTCDate()
|
||||||
|
|
||||||
|
let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs
|
||||||
|
if (resetAtMs <= now.getTime()) {
|
||||||
|
resetAtMs += 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(resetAtMs).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null) {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
|
||||||
|
const currency = balanceData.currency || 'USD'
|
||||||
|
|
||||||
|
let cacheExpiresAt = null
|
||||||
|
if (source === 'cache') {
|
||||||
|
const ttl =
|
||||||
|
typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS
|
||||||
|
cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
balance:
|
||||||
|
typeof amount === 'number'
|
||||||
|
? {
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
formattedAmount: this._formatCurrency(amount, currency)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
quota: balanceData.quota || null,
|
||||||
|
statistics: balanceData.statistics || {},
|
||||||
|
source,
|
||||||
|
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
|
||||||
|
cacheExpiresAt,
|
||||||
|
status: balanceData.status || 'success',
|
||||||
|
error: balanceData.errorMessage || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatCurrency(amount, currency = 'USD') {
|
||||||
|
try {
|
||||||
|
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
||||||
|
return 'N/A'
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
|
||||||
|
} catch (error) {
|
||||||
|
return `$${amount.toFixed(2)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseBoolean(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async _mapWithConcurrency(items, limit, mapper) {
|
||||||
|
const concurrency = Math.max(1, Number(limit) || 1)
|
||||||
|
const list = Array.isArray(items) ? items : []
|
||||||
|
|
||||||
|
const results = new Array(list.length)
|
||||||
|
let nextIndex = 0
|
||||||
|
|
||||||
|
const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => {
|
||||||
|
while (nextIndex < list.length) {
|
||||||
|
const currentIndex = nextIndex
|
||||||
|
nextIndex += 1
|
||||||
|
results[currentIndex] = await mapper(list[currentIndex], currentIndex)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountBalanceService = new AccountBalanceService()
|
||||||
|
module.exports = accountBalanceService
|
||||||
|
module.exports.AccountBalanceService = AccountBalanceService
|
||||||
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const ProxyHelper = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider 抽象基类
|
||||||
|
* 各平台 Provider 需继承并实现 queryBalance(account)
|
||||||
|
*/
|
||||||
|
class BaseBalanceProvider {
|
||||||
|
constructor(platform) {
|
||||||
|
this.platform = platform
|
||||||
|
this.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询余额(抽象方法)
|
||||||
|
* @param {object} account - 账户对象
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
* 形如:
|
||||||
|
* {
|
||||||
|
* balance: number|null,
|
||||||
|
* currency?: string,
|
||||||
|
* quota?: { daily, used, remaining, resetAt, percentage, unlimited? },
|
||||||
|
* queryMethod?: 'api'|'field'|'local',
|
||||||
|
* rawData?: any
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
async queryBalance(_account) {
|
||||||
|
throw new Error('queryBalance 方法必须由子类实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用 HTTP 请求方法(支持代理)
|
||||||
|
* @param {string} url
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} account
|
||||||
|
*/
|
||||||
|
async makeRequest(url, options = {}, account = {}) {
|
||||||
|
const config = {
|
||||||
|
url,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: options.headers || {},
|
||||||
|
timeout: options.timeout || 15000,
|
||||||
|
data: options.data,
|
||||||
|
params: options.params,
|
||||||
|
responseType: options.responseType
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyConfig = account.proxyConfig || account.proxy
|
||||||
|
if (proxyConfig) {
|
||||||
|
const agent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
if (agent) {
|
||||||
|
config.httpAgent = agent
|
||||||
|
config.httpsAgent = agent
|
||||||
|
config.proxy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(config)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data,
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.response?.status
|
||||||
|
const message = error.response?.data?.message || error.message || '请求失败'
|
||||||
|
this.logger.debug(`余额 Provider HTTP 请求失败: ${url} (${this.platform})`, {
|
||||||
|
status,
|
||||||
|
message
|
||||||
|
})
|
||||||
|
return { success: false, status, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从账户字段读取 dailyQuota / dailyUsage(通用降级方案)
|
||||||
|
* 注意:部分平台 dailyUsage 字段可能不是实时值,最终以 AccountBalanceService 的本地统计为准
|
||||||
|
*/
|
||||||
|
readQuotaFromFields(account) {
|
||||||
|
const dailyQuota = Number(account?.dailyQuota || 0)
|
||||||
|
const dailyUsage = Number(account?.dailyUsage || 0)
|
||||||
|
|
||||||
|
// 无限制
|
||||||
|
if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: Infinity,
|
||||||
|
used: Number.isFinite(dailyUsage) ? dailyUsage : 0,
|
||||||
|
remaining: Infinity,
|
||||||
|
percentage: 0,
|
||||||
|
unlimited: true
|
||||||
|
},
|
||||||
|
queryMethod: 'field'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = Number.isFinite(dailyUsage) ? dailyUsage : 0
|
||||||
|
const remaining = Math.max(0, dailyQuota - used)
|
||||||
|
const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: remaining,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
daily: dailyQuota,
|
||||||
|
used,
|
||||||
|
remaining,
|
||||||
|
percentage: Math.round(percentage * 100) / 100
|
||||||
|
},
|
||||||
|
queryMethod: 'field'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCurrency(data) {
|
||||||
|
return data?.currency || data?.Currency || 'USD'
|
||||||
|
}
|
||||||
|
|
||||||
|
async safeExecute(fn, fallbackValue = null) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`余额 Provider 执行失败: ${this.platform}`, error)
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BaseBalanceProvider
|
||||||
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
const claudeAccountService = require('../claudeAccountService')
|
||||||
|
|
||||||
|
class ClaudeBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('claude')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude(OAuth):优先尝试获取 OAuth usage(用于配额/使用信息),不强行提供余额金额
|
||||||
|
*/
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`查询 Claude 余额(OAuth usage): ${account?.id}`)
|
||||||
|
|
||||||
|
// 仅 OAuth 账户可用;失败时降级
|
||||||
|
const usageData = await claudeAccountService.fetchOAuthUsage(account.id).catch(() => null)
|
||||||
|
if (!usageData) {
|
||||||
|
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: usageData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ClaudeBalanceProvider
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
|
||||||
|
class ClaudeConsoleBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('claude-console')
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`查询 Claude Console 余额(字段): ${account?.id}`)
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ClaudeConsoleBalanceProvider
|
||||||
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
|
||||||
|
class GenericBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor(platform) {
|
||||||
|
super(platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`${this.platform} 暂无专用余额 API,实现降级策略`)
|
||||||
|
|
||||||
|
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
queryMethod: 'local'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GenericBalanceProvider
|
||||||
24
src/services/balanceProviders/index.js
Normal file
24
src/services/balanceProviders/index.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const ClaudeBalanceProvider = require('./claudeBalanceProvider')
|
||||||
|
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
|
||||||
|
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
|
||||||
|
const GenericBalanceProvider = require('./genericBalanceProvider')
|
||||||
|
|
||||||
|
function registerAllProviders(balanceService) {
|
||||||
|
// Claude
|
||||||
|
balanceService.registerProvider('claude', new ClaudeBalanceProvider())
|
||||||
|
balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider())
|
||||||
|
|
||||||
|
// OpenAI / Codex
|
||||||
|
balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider())
|
||||||
|
balanceService.registerProvider('openai', new GenericBalanceProvider('openai'))
|
||||||
|
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
|
||||||
|
|
||||||
|
// 其他平台(降级)
|
||||||
|
balanceService.registerProvider('gemini', new GenericBalanceProvider('gemini'))
|
||||||
|
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
|
||||||
|
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
|
||||||
|
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
|
||||||
|
balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr'))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAllProviders }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
|
||||||
|
class OpenAIResponsesBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('openai-responses')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI-Responses:
|
||||||
|
* - 优先使用 dailyQuota 字段(如果配置了额度)
|
||||||
|
* - 可选:尝试调用兼容 API(不同服务商实现不一,失败自动降级)
|
||||||
|
*/
|
||||||
|
async queryBalance(account) {
|
||||||
|
this.logger.debug(`查询 OpenAI Responses 余额: ${account?.id}`)
|
||||||
|
|
||||||
|
// 配置了额度时直接返回(字段法)
|
||||||
|
if (account?.dailyQuota && Number(account.dailyQuota) > 0) {
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试调用 usage 接口(兼容性不保证)
|
||||||
|
if (account?.apiKey && account?.baseApi) {
|
||||||
|
const baseApi = String(account.baseApi).replace(/\/$/, '')
|
||||||
|
const response = await this.makeRequest(
|
||||||
|
`${baseApi}/v1/usage`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${account.apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
account
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: this.parseCurrency(response.data),
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
queryMethod: 'local'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OpenAIResponsesBalanceProvider
|
||||||
142
tests/accountBalanceService.test.js
Normal file
142
tests/accountBalanceService.test.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// Mock logger,避免测试输出污染控制台
|
||||||
|
jest.mock('../src/utils/logger', () => ({
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const accountBalanceServiceModule = require('../src/services/accountBalanceService')
|
||||||
|
|
||||||
|
const { AccountBalanceService } = accountBalanceServiceModule
|
||||||
|
|
||||||
|
describe('AccountBalanceService', () => {
|
||||||
|
const mockLogger = {
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMockRedis = () => ({
|
||||||
|
getLocalBalance: jest.fn().mockResolvedValue(null),
|
||||||
|
setLocalBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getAccountBalance: jest.fn().mockResolvedValue(null),
|
||||||
|
setAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getAccountUsageStats: jest.fn().mockResolvedValue({
|
||||||
|
total: { requests: 10 },
|
||||||
|
daily: { requests: 2, cost: 20 },
|
||||||
|
monthly: { requests: 5 }
|
||||||
|
}),
|
||||||
|
getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize platform aliases', () => {
|
||||||
|
const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger })
|
||||||
|
expect(service.normalizePlatform('claude-official')).toBe('claude')
|
||||||
|
expect(service.normalizePlatform('azure-openai')).toBe('azure_openai')
|
||||||
|
expect(service.normalizePlatform('gemini-api')).toBe('gemini-api')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should build local quota/balance from dailyQuota and local dailyCost', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(30)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(123.45)
|
||||||
|
|
||||||
|
const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'claude-console', {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data.source).toBe('local')
|
||||||
|
expect(result.data.balance.amount).toBeCloseTo(80, 6)
|
||||||
|
expect(result.data.quota.percentage).toBeCloseTo(20, 6)
|
||||||
|
expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6)
|
||||||
|
expect(mockRedis.setLocalBalance).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use cached balance when account has no dailyQuota', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
mockRedis.getAccountBalance.mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
balance: 12.34,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
errorMessage: '',
|
||||||
|
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||||
|
ttlSeconds: 120
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
const account = { id: 'acct-2', name: 'B' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: false,
|
||||||
|
useCache: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data.source).toBe('cache')
|
||||||
|
expect(result.data.balance.amount).toBeCloseTo(12.34, 6)
|
||||||
|
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cache provider errors and fallback to local when queryApi=true', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
service.registerProvider('openai', {
|
||||||
|
queryBalance: () => {
|
||||||
|
throw new Error('boom')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const account = { id: 'acct-3', name: 'C' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockRedis.setAccountBalance).toHaveBeenCalled()
|
||||||
|
expect(result.data.source).toBe('local')
|
||||||
|
expect(result.data.status).toBe('error')
|
||||||
|
expect(result.data.error).toBe('boom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should count low balance once per account in summary', async () => {
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
|
service.getSupportedPlatforms = () => ['claude-console']
|
||||||
|
service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }]
|
||||||
|
service._getAccountBalanceForAccount = async () => ({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId: 'acct-4',
|
||||||
|
platform: 'claude-console',
|
||||||
|
balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' },
|
||||||
|
quota: { percentage: 95 },
|
||||||
|
statistics: { totalCost: 1 },
|
||||||
|
source: 'local',
|
||||||
|
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||||
|
cacheExpiresAt: null,
|
||||||
|
status: 'success',
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = await service.getBalanceSummary()
|
||||||
|
expect(summary.lowBalanceCount).toBe(1)
|
||||||
|
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
261
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
261
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal 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>
|
||||||
@@ -141,6 +141,32 @@
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</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
|
<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"
|
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>
|
||||||
|
<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
|
<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"
|
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>
|
||||||
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
||||||
</td>
|
</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">
|
<td class="whitespace-nowrap px-3 py-4">
|
||||||
<div v-if="account.platform === 'claude'" class="space-y-2">
|
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||||||
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||||||
@@ -1425,6 +1465,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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 ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||||
|
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
|
||||||
|
|
||||||
// 使用确认弹窗
|
// 使用确认弹窗
|
||||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||||
@@ -2069,6 +2122,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
|
|||||||
// 数据状态
|
// 数据状态
|
||||||
const accounts = ref([])
|
const accounts = ref([])
|
||||||
const accountsLoading = ref(false)
|
const accountsLoading = ref(false)
|
||||||
|
const refreshingBalances = ref(false)
|
||||||
const accountsSortBy = ref('name')
|
const accountsSortBy = ref('name')
|
||||||
const accountsSortOrder = ref('asc')
|
const accountsSortOrder = ref('asc')
|
||||||
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
||||||
@@ -2768,6 +2822,72 @@ const paginatedAccounts = computed(() => {
|
|||||||
return sortedAccounts.value.slice(start, end)
|
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 updateSelectAllState = () => {
|
||||||
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
||||||
const selectedInCurrentPage = currentIds.filter((id) =>
|
const selectedInCurrentPage = currentIds.filter((id) =>
|
||||||
@@ -2818,6 +2938,54 @@ const cleanupSelectedAccounts = () => {
|
|||||||
updateSelectAllState()
|
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) => {
|
const loadAccounts = async (forceReload = false) => {
|
||||||
accountsLoading.value = true
|
accountsLoading.value = true
|
||||||
@@ -3010,6 +3178,11 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
console.debug('Claude usage loading failed:', err)
|
console.debug('Claude usage loading failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 异步加载余额缓存(按平台批量)
|
||||||
|
loadBalanceCacheForAccounts().catch((err) => {
|
||||||
|
console.debug('Balance cache loading failed:', err)
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载账户失败', 'error')
|
showToast('加载账户失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -196,6 +196,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</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统计和性能指标 -->
|
<!-- Token统计和性能指标 -->
|
||||||
<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 lg:grid-cols-4"
|
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 { storeToRefs } from 'pinia'
|
||||||
import { useDashboardStore } from '@/stores/dashboard'
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
import Chart from 'chart.js/auto'
|
import Chart from 'chart.js/auto'
|
||||||
|
|
||||||
const dashboardStore = useDashboardStore()
|
const dashboardStore = useDashboardStore()
|
||||||
@@ -732,6 +833,97 @@ const accountGroupOptions = [
|
|||||||
|
|
||||||
const accountTrendUpdating = ref(false)
|
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 autoRefreshEnabled = ref(false)
|
||||||
const autoRefreshInterval = ref(30) // 秒
|
const autoRefreshInterval = ref(30) // 秒
|
||||||
@@ -1488,7 +1680,7 @@ async function refreshAllData() {
|
|||||||
|
|
||||||
isRefreshing.value = true
|
isRefreshing.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadDashboardData(), refreshChartsData()])
|
await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()])
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing.value = false
|
isRefreshing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user