mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat(admin): 余额脚本驱动的余额/配额刷新与管理端体验修复
- 明确刷新语义:仅脚本启用且已配置时触发远程查询;未配置时前端禁用并提示\n- 新增余额脚本安全开关 BALANCE_SCRIPT_ENABLED(默认开启),脚本测试接口受控\n- Redis 增加单账户脚本配置存取,响应透出 scriptEnabled/scriptConfigured 供 UI 判定\n- accountBalanceService:本地统计汇总改用 SCAN+pipeline,避免 KEYS;仅缓存远程成功结果,避免失败/降级覆盖有效缓存\n- 管理端体验:刷新按钮按配置状态灰置;脚本弹窗内容可滚动、底部操作栏固定,并 append-to-body 使弹窗跟随当前视窗
This commit is contained in:
@@ -205,6 +205,14 @@ const config = {
|
|||||||
hotReload: process.env.HOT_RELOAD === 'true'
|
hotReload: process.env.HOT_RELOAD === 'true'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 💰 账户余额相关配置
|
||||||
|
accountBalance: {
|
||||||
|
// 是否允许执行自定义余额脚本(安全开关)
|
||||||
|
// 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
|
||||||
|
// 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false
|
||||||
|
enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
|
||||||
|
},
|
||||||
|
|
||||||
// 📬 用户消息队列配置
|
// 📬 用户消息队列配置
|
||||||
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
|
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
|
||||||
userMessageQueue: {
|
userMessageQueue: {
|
||||||
|
|||||||
@@ -1615,15 +1615,17 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🧩 账户余额脚本配置
|
// 🧩 账户余额脚本配置
|
||||||
async setBalanceScriptConfig(platform, accountId, config) {
|
async setBalanceScriptConfig(platform, accountId, scriptConfig) {
|
||||||
const key = `account_balance_script:${platform}:${accountId}`
|
const key = `account_balance_script:${platform}:${accountId}`
|
||||||
await this.client.set(key, JSON.stringify(config || {}))
|
await this.client.set(key, JSON.stringify(scriptConfig || {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBalanceScriptConfig(platform, accountId) {
|
async getBalanceScriptConfig(platform, accountId) {
|
||||||
const key = `account_balance_script:${platform}:${accountId}`
|
const key = `account_balance_script:${platform}:${accountId}`
|
||||||
const raw = await this.client.get(key)
|
const raw = await this.client.get(key)
|
||||||
if (!raw) return null
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw)
|
return JSON.parse(raw)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { authenticateAdmin } = require('../../middleware/auth')
|
|||||||
const logger = require('../../utils/logger')
|
const logger = require('../../utils/logger')
|
||||||
const accountBalanceService = require('../../services/accountBalanceService')
|
const accountBalanceService = require('../../services/accountBalanceService')
|
||||||
const balanceScriptService = require('../../services/balanceScriptService')
|
const balanceScriptService = require('../../services/balanceScriptService')
|
||||||
|
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2) 强制刷新账户余额(触发 Provider)
|
// 2) 强制刷新账户余额(强制触发查询:优先脚本;Provider 仅为降级)
|
||||||
// POST /admin/accounts/:accountId/balance/refresh
|
// POST /admin/accounts/:accountId/balance/refresh
|
||||||
// Body: { platform: 'xxx' }
|
// Body: { platform: 'xxx' }
|
||||||
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
|
router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => {
|
||||||
@@ -174,6 +175,12 @@ router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async
|
|||||||
return res.status(valid.status).json({ success: false, error: valid.error })
|
return res.status(valid.status).json({ success: false, error: valid.error })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isBalanceScriptEnabled()) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ success: false, error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)' })
|
||||||
|
}
|
||||||
|
|
||||||
const payload = req.body || {}
|
const payload = req.body || {}
|
||||||
const scriptBody = payload.scriptBody
|
const scriptBody = payload.scriptBody
|
||||||
if (!scriptBody) {
|
if (!scriptBody) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ 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 accountBalanceRoutes = require('./accountBalance')
|
||||||
const balanceScriptsRoutes = require('./balanceScripts')
|
|
||||||
const systemRoutes = require('./system')
|
const systemRoutes = require('./system')
|
||||||
const concurrencyRoutes = require('./concurrency')
|
const concurrencyRoutes = require('./concurrency')
|
||||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||||
@@ -39,7 +38,6 @@ router.use('/', droidAccountsRoutes)
|
|||||||
router.use('/', dashboardRoutes)
|
router.use('/', dashboardRoutes)
|
||||||
router.use('/', usageStatsRoutes)
|
router.use('/', usageStatsRoutes)
|
||||||
router.use('/', accountBalanceRoutes)
|
router.use('/', accountBalanceRoutes)
|
||||||
router.use('/', balanceScriptsRoutes)
|
|
||||||
router.use('/', systemRoutes)
|
router.use('/', systemRoutes)
|
||||||
router.use('/', concurrencyRoutes)
|
router.use('/', concurrencyRoutes)
|
||||||
router.use('/', claudeRelayConfigRoutes)
|
router.use('/', claudeRelayConfigRoutes)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const redis = require('../models/redis')
|
|||||||
const balanceScriptService = require('./balanceScriptService')
|
const balanceScriptService = require('./balanceScriptService')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const CostCalculator = require('../utils/costCalculator')
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
|
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||||
|
|
||||||
class AccountBalanceService {
|
class AccountBalanceService {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
@@ -277,6 +278,20 @@ class AccountBalanceService {
|
|||||||
throw new Error('账户缺少 id')
|
throw new Error('账户缺少 id')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 余额脚本配置状态(用于前端控制“刷新余额”按钮)
|
||||||
|
let scriptConfig = null
|
||||||
|
let scriptConfigured = false
|
||||||
|
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
|
||||||
|
scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
|
||||||
|
scriptConfigured = !!(
|
||||||
|
scriptConfig &&
|
||||||
|
scriptConfig.scriptBody &&
|
||||||
|
String(scriptConfig.scriptBody).trim().length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const scriptEnabled = isBalanceScriptEnabled()
|
||||||
|
const scriptMeta = { scriptEnabled, scriptConfigured }
|
||||||
|
|
||||||
const localBalance = await this._getBalanceFromLocal(accountId, platform)
|
const localBalance = await this._getBalanceFromLocal(accountId, platform)
|
||||||
const localStatistics = localBalance.statistics || {}
|
const localStatistics = localBalance.statistics || {}
|
||||||
|
|
||||||
@@ -300,7 +315,8 @@ class AccountBalanceService {
|
|||||||
accountId,
|
accountId,
|
||||||
platform,
|
platform,
|
||||||
'cache',
|
'cache',
|
||||||
cached.ttlSeconds
|
cached.ttlSeconds,
|
||||||
|
scriptMeta
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,15 +333,16 @@ class AccountBalanceService {
|
|||||||
},
|
},
|
||||||
accountId,
|
accountId,
|
||||||
platform,
|
platform,
|
||||||
'local'
|
'local',
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 强制查询:调用 Provider,失败自动降级到本地统计
|
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||||
const scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
|
|
||||||
let providerResult
|
let providerResult
|
||||||
|
|
||||||
if (scriptConfig && scriptConfig.scriptBody) {
|
if (scriptEnabled && scriptConfigured) {
|
||||||
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
|
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
|
||||||
} else {
|
} else {
|
||||||
const provider = this.providers.get(platform)
|
const provider = this.providers.get(platform)
|
||||||
@@ -342,15 +359,28 @@ class AccountBalanceService {
|
|||||||
},
|
},
|
||||||
accountId,
|
accountId,
|
||||||
platform,
|
platform,
|
||||||
'local'
|
'local',
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
providerResult = await this._getBalanceFromProvider(provider, account)
|
providerResult = await this._getBalanceFromProvider(provider, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.redis.setAccountBalance(platform, accountId, providerResult, this.CACHE_TTL_SECONDS)
|
const isRemoteSuccess =
|
||||||
|
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
|
||||||
|
|
||||||
const source = providerResult.status === 'success' ? 'api' : 'local'
|
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
|
||||||
|
if (isRemoteSuccess) {
|
||||||
|
await this.redis.setAccountBalance(
|
||||||
|
platform,
|
||||||
|
accountId,
|
||||||
|
providerResult,
|
||||||
|
this.CACHE_TTL_SECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = isRemoteSuccess ? 'api' : 'local'
|
||||||
|
|
||||||
return this._buildResponse(
|
return this._buildResponse(
|
||||||
{
|
{
|
||||||
@@ -364,7 +394,9 @@ class AccountBalanceService {
|
|||||||
},
|
},
|
||||||
accountId,
|
accountId,
|
||||||
platform,
|
platform,
|
||||||
source
|
source,
|
||||||
|
null,
|
||||||
|
scriptMeta
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,35 +539,50 @@ class AccountBalanceService {
|
|||||||
async _sumModelCostsByKeysPattern(pattern) {
|
async _sumModelCostsByKeysPattern(pattern) {
|
||||||
try {
|
try {
|
||||||
const client = this.redis.getClientSafe()
|
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
|
let totalCost = 0
|
||||||
for (let i = 0; i < results.length; i += 1) {
|
let cursor = '0'
|
||||||
const [, data] = results[i] || []
|
const scanCount = 200
|
||||||
if (!data || Object.keys(data).length === 0) {
|
let iterations = 0
|
||||||
|
const maxIterations = 2000
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount)
|
||||||
|
cursor = nextCursor
|
||||||
|
iterations += 1
|
||||||
|
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = String(keys[i]).split(':')
|
const pipeline = client.pipeline()
|
||||||
const model = parts[4] || 'unknown'
|
keys.forEach((key) => pipeline.hgetall(key))
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
const usage = {
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
input_tokens: parseInt(data.inputTokens || 0),
|
const [, data] = results[i] || []
|
||||||
output_tokens: parseInt(data.outputTokens || 0),
|
if (!data || Object.keys(data).length === 0) {
|
||||||
cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0),
|
continue
|
||||||
cache_read_input_tokens: parseInt(data.cacheReadTokens || 0)
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
const costResult = CostCalculator.calculateCost(usage, model)
|
if (iterations >= maxIterations) {
|
||||||
totalCost += costResult.costs.total || 0
|
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
|
||||||
}
|
break
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
return totalCost
|
return totalCost
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -610,7 +657,7 @@ class AccountBalanceService {
|
|||||||
return new Date(resetAtMs).toISOString()
|
return new Date(resetAtMs).toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null) {
|
_buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
|
const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null
|
||||||
@@ -642,7 +689,8 @@ class AccountBalanceService {
|
|||||||
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
|
lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(),
|
||||||
cacheExpiresAt,
|
cacheExpiresAt,
|
||||||
status: balanceData.status || 'success',
|
status: balanceData.status || 'success',
|
||||||
error: balanceData.errorMessage || null
|
error: balanceData.errorMessage || null,
|
||||||
|
...(extraData && typeof extraData === 'object' ? extraData : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,13 @@
|
|||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
const vm = require('vm')
|
const vm = require('vm')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const logger = require('../utils/logger')
|
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可配置脚本余额查询服务
|
* 可配置脚本余额查询执行器
|
||||||
* - 存储位置:data/balanceScripts.json
|
|
||||||
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
||||||
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
|
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
|
||||||
*/
|
*/
|
||||||
class BalanceScriptService {
|
class BalanceScriptService {
|
||||||
constructor() {
|
|
||||||
this.filePath = path.join(__dirname, '..', '..', 'data', 'balanceScripts.json')
|
|
||||||
this.ensureStore()
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureStore() {
|
|
||||||
const dir = path.dirname(this.filePath)
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(this.filePath)) {
|
|
||||||
fs.writeFileSync(this.filePath, JSON.stringify({}, null, 2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAll() {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(this.filePath, 'utf8')
|
|
||||||
return JSON.parse(raw || '{}')
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('读取余额脚本配置失败', error)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveAll(data) {
|
|
||||||
fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
listConfigs() {
|
|
||||||
const all = this.loadAll()
|
|
||||||
return Object.values(all)
|
|
||||||
}
|
|
||||||
|
|
||||||
getConfig(name) {
|
|
||||||
const all = this.loadAll()
|
|
||||||
if (all[name]) {
|
|
||||||
return all[name]
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
baseUrl: '',
|
|
||||||
apiKey: '',
|
|
||||||
token: '',
|
|
||||||
timeoutSeconds: 10,
|
|
||||||
autoIntervalMinutes: 0,
|
|
||||||
scriptBody:
|
|
||||||
"({\n request: {\n url: \"{{baseUrl}}/user/balance\",\n method: \"GET\",\n headers: {\n \"Authorization\": \"Bearer {{apiKey}}\",\n \"User-Agent\": \"cc-switch/1.0\"\n }\n },\n extractor: function(response) {\n return {\n isValid: !response.error,\n remaining: response.balance,\n unit: \"USD\"\n };\n }\n})",
|
|
||||||
updatedAt: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveConfig(name, payload) {
|
|
||||||
const all = this.loadAll()
|
|
||||||
const config = {
|
|
||||||
...this.getConfig(name),
|
|
||||||
...payload,
|
|
||||||
name,
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
all[name] = config
|
|
||||||
this.saveAll(all)
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行脚本:返回标准余额结构 + 原始响应
|
* 执行脚本:返回标准余额结构 + 原始响应
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
@@ -84,6 +16,12 @@ class BalanceScriptService {
|
|||||||
* - timeoutSeconds: number
|
* - timeoutSeconds: number
|
||||||
*/
|
*/
|
||||||
async execute(options = {}) {
|
async execute(options = {}) {
|
||||||
|
if (!isBalanceScriptEnabled()) {
|
||||||
|
const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)')
|
||||||
|
error.code = 'BALANCE_SCRIPT_DISABLED'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
const scriptBody = options.scriptBody?.trim()
|
const scriptBody = options.scriptBody?.trim()
|
||||||
if (!scriptBody) {
|
if (!scriptBody) {
|
||||||
throw new Error('脚本内容为空')
|
throw new Error('脚本内容为空')
|
||||||
@@ -99,7 +37,7 @@ class BalanceScriptService {
|
|||||||
let scriptResult
|
let scriptResult
|
||||||
try {
|
try {
|
||||||
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
|
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
|
||||||
const script = new vm.Script(wrapped, { timeout: timeoutMs })
|
const script = new vm.Script(wrapped)
|
||||||
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
|
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`脚本解析失败: ${error.message}`)
|
throw new Error(`脚本解析失败: ${error.message}`)
|
||||||
@@ -111,12 +49,16 @@ class BalanceScriptService {
|
|||||||
|
|
||||||
const variables = options.variables || {}
|
const variables = options.variables || {}
|
||||||
const request = this.applyTemplates(scriptResult.request || {}, variables)
|
const request = this.applyTemplates(scriptResult.request || {}, variables)
|
||||||
const extractor = scriptResult.extractor
|
const { extractor } = scriptResult
|
||||||
|
|
||||||
if (!request.url) {
|
if (!request?.url || typeof request.url !== 'string') {
|
||||||
throw new Error('脚本 request.url 不能为空')
|
throw new Error('脚本 request.url 不能为空')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof extractor !== 'function') {
|
||||||
|
throw new Error('脚本 extractor 必须是函数')
|
||||||
|
}
|
||||||
|
|
||||||
const axiosConfig = {
|
const axiosConfig = {
|
||||||
url: request.url,
|
url: request.url,
|
||||||
method: (request.method || 'GET').toUpperCase(),
|
method: (request.method || 'GET').toUpperCase(),
|
||||||
@@ -131,23 +73,24 @@ class BalanceScriptService {
|
|||||||
axiosConfig.data = request.body || request.data
|
axiosConfig.data = request.body || request.data
|
||||||
}
|
}
|
||||||
|
|
||||||
let httpResponse = null
|
let httpResponse
|
||||||
try {
|
try {
|
||||||
httpResponse = await axios(axiosConfig)
|
httpResponse = await axios(axiosConfig)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error.response?.status
|
const { response } = error || {}
|
||||||
const data = error.response?.data
|
const { status, data } = response || {}
|
||||||
throw new Error(`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`)
|
throw new Error(
|
||||||
|
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = httpResponse?.data
|
const responseData = httpResponse?.data
|
||||||
|
|
||||||
let extracted = {}
|
let extracted = {}
|
||||||
if (typeof extractor === 'function') {
|
try {
|
||||||
try {
|
extracted = extractor(responseData) || {}
|
||||||
extracted = extractor(responseData) || {}
|
} catch (error) {
|
||||||
} catch (error) {
|
throw new Error(`extractor 执行失败: ${error.message}`)
|
||||||
throw new Error(`extractor 执行失败: ${error.message}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped = this.mapExtractorResult(extracted, responseData)
|
const mapped = this.mapExtractorResult(extracted, responseData)
|
||||||
@@ -213,29 +156,6 @@ class BalanceScriptService {
|
|||||||
rawData: responseData || result.raw
|
rawData: responseData || result.raw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async testScript(name, payload = {}) {
|
|
||||||
const config = payload.useBodyConfig ? this.getConfig(name) : this.getConfig(name)
|
|
||||||
const scriptBody = payload.scriptBody || config.scriptBody
|
|
||||||
const timeoutSeconds = payload.timeoutSeconds || config.timeoutSeconds
|
|
||||||
const variables = {
|
|
||||||
baseUrl: payload.baseUrl || config.baseUrl,
|
|
||||||
apiKey: payload.apiKey || config.apiKey,
|
|
||||||
token: payload.token || config.token,
|
|
||||||
accountId: payload.accountId || '',
|
|
||||||
platform: payload.platform || '',
|
|
||||||
extra: payload.extra || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.execute({ scriptBody, variables, timeoutSeconds })
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
variables,
|
|
||||||
mapped: result.mapped,
|
|
||||||
extracted: result.extracted,
|
|
||||||
response: result.response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new BalanceScriptService()
|
module.exports = new BalanceScriptService()
|
||||||
|
|||||||
44
src/utils/featureFlags.js
Normal file
44
src/utils/featureFlags.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
let config = {}
|
||||||
|
try {
|
||||||
|
// config/config.js 可能在某些环境不存在(例如仅拷贝了 config.example.js)
|
||||||
|
// 为保证可运行,这里做容错处理
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
config = require('../../config/config')
|
||||||
|
} catch (error) {
|
||||||
|
config = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseBooleanEnv = (value) => {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许执行“余额脚本”(安全开关)
|
||||||
|
* 默认开启,便于保持现有行为;如需禁用请显式设置 BALANCE_SCRIPT_ENABLED=false(环境变量优先)
|
||||||
|
*/
|
||||||
|
const isBalanceScriptEnabled = () => {
|
||||||
|
if (
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED !== undefined &&
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED !== ''
|
||||||
|
) {
|
||||||
|
return parseBooleanEnv(process.env.BALANCE_SCRIPT_ENABLED)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromConfig =
|
||||||
|
config?.accountBalance?.enableBalanceScript ??
|
||||||
|
config?.features?.balanceScriptEnabled ??
|
||||||
|
config?.security?.enableBalanceScript
|
||||||
|
|
||||||
|
return typeof fromConfig === 'boolean' ? fromConfig : true
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isBalanceScriptEnabled
|
||||||
|
}
|
||||||
@@ -11,6 +11,16 @@ const accountBalanceServiceModule = require('../src/services/accountBalanceServi
|
|||||||
const { AccountBalanceService } = accountBalanceServiceModule
|
const { AccountBalanceService } = accountBalanceServiceModule
|
||||||
|
|
||||||
describe('AccountBalanceService', () => {
|
describe('AccountBalanceService', () => {
|
||||||
|
const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalBalanceScriptEnabled === undefined) {
|
||||||
|
delete process.env.BALANCE_SCRIPT_ENABLED
|
||||||
|
} else {
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const mockLogger = {
|
const mockLogger = {
|
||||||
debug: jest.fn(),
|
debug: jest.fn(),
|
||||||
info: jest.fn(),
|
info: jest.fn(),
|
||||||
@@ -24,6 +34,7 @@ describe('AccountBalanceService', () => {
|
|||||||
getAccountBalance: jest.fn().mockResolvedValue(null),
|
getAccountBalance: jest.fn().mockResolvedValue(null),
|
||||||
setAccountBalance: jest.fn().mockResolvedValue(undefined),
|
setAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
|
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getBalanceScriptConfig: jest.fn().mockResolvedValue(null),
|
||||||
getAccountUsageStats: jest.fn().mockResolvedValue({
|
getAccountUsageStats: jest.fn().mockResolvedValue({
|
||||||
total: { requests: 10 },
|
total: { requests: 10 },
|
||||||
daily: { requests: 2, cost: 20 },
|
daily: { requests: 2, cost: 20 },
|
||||||
@@ -87,7 +98,7 @@ describe('AccountBalanceService', () => {
|
|||||||
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should cache provider errors and fallback to local when queryApi=true', async () => {
|
it('should not cache provider errors and fallback to local when queryApi=true', async () => {
|
||||||
const mockRedis = buildMockRedis()
|
const mockRedis = buildMockRedis()
|
||||||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
|
||||||
@@ -106,12 +117,78 @@ describe('AccountBalanceService', () => {
|
|||||||
useCache: false
|
useCache: false
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(mockRedis.setAccountBalance).toHaveBeenCalled()
|
expect(mockRedis.setAccountBalance).not.toHaveBeenCalled()
|
||||||
expect(result.data.source).toBe('local')
|
expect(result.data.source).toBe('local')
|
||||||
expect(result.data.status).toBe('error')
|
expect(result.data.status).toBe('error')
|
||||||
expect(result.data.error).toBe('boom')
|
expect(result.data.error).toBe('boom')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should ignore script config when balance script is disabled', async () => {
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED = 'false'
|
||||||
|
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||||||
|
scriptBody: '({ request: { url: \"http://example.com\" }, extractor: function(){ return {} } })'
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) }
|
||||||
|
service.registerProvider('openai', provider)
|
||||||
|
|
||||||
|
const scriptSpy = jest.spyOn(service, '_getBalanceFromScript')
|
||||||
|
|
||||||
|
const account = { id: 'acct-script-off', name: 'S' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(provider.queryBalance).toHaveBeenCalled()
|
||||||
|
expect(scriptSpy).not.toHaveBeenCalled()
|
||||||
|
expect(result.data.source).toBe('api')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prefer script when configured and enabled', async () => {
|
||||||
|
process.env.BALANCE_SCRIPT_ENABLED = 'true'
|
||||||
|
|
||||||
|
const mockRedis = buildMockRedis()
|
||||||
|
mockRedis.getBalanceScriptConfig.mockResolvedValue({
|
||||||
|
scriptBody: '({ request: { url: \"http://example.com\" }, extractor: function(){ return {} } })'
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
|
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||||||
|
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||||||
|
|
||||||
|
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) }
|
||||||
|
service.registerProvider('openai', provider)
|
||||||
|
|
||||||
|
jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
balance: 3,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'script',
|
||||||
|
rawData: { ok: true },
|
||||||
|
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||||||
|
errorMessage: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const account = { id: 'acct-script-on', name: 'T' }
|
||||||
|
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||||||
|
queryApi: true,
|
||||||
|
useCache: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(provider.queryBalance).not.toHaveBeenCalled()
|
||||||
|
expect(result.data.source).toBe('api')
|
||||||
|
expect(result.data.balance.amount).toBeCloseTo(3, 6)
|
||||||
|
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
it('should count low balance once per account in summary', async () => {
|
it('should count low balance once per account in summary', async () => {
|
||||||
const mockRedis = buildMockRedis()
|
const mockRedis = buildMockRedis()
|
||||||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||||||
@@ -139,4 +216,3 @@ describe('AccountBalanceService', () => {
|
|||||||
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
|
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog
|
<el-dialog
|
||||||
|
:append-to-body="true"
|
||||||
|
class="balance-script-dialog"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:destroy-on-close="true"
|
||||||
:model-value="show"
|
:model-value="show"
|
||||||
:title="`配置余额脚本 - ${account?.name || ''}`"
|
:title="`配置余额脚本 - ${account?.name || ''}`"
|
||||||
|
top="5vh"
|
||||||
width="720px"
|
width="720px"
|
||||||
@close="emitClose"
|
@close="emitClose"
|
||||||
>
|
>
|
||||||
@@ -71,12 +76,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
|
|
||||||
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
|
|
||||||
<el-button @click="emitClose">取消</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
|
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-semibold">测试结果</span>
|
<span class="font-semibold">测试结果</span>
|
||||||
@@ -113,6 +112,14 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
|
||||||
|
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
|
||||||
|
<el-button @click="emitClose">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -256,6 +263,22 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
:deep(.balance-script-dialog) {
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.balance-script-dialog .el-dialog__body) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.balance-script-dialog .el-dialog__footer) {
|
||||||
|
border-top: 1px solid rgba(229, 231, 235, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
.input-text {
|
.input-text {
|
||||||
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,9 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!hideRefresh"
|
v-if="!hideRefresh"
|
||||||
class="text-xs text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
class="text-xs text-gray-500 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:text-blue-400"
|
||||||
:disabled="refreshing"
|
:disabled="refreshing || !canRefresh"
|
||||||
:title="refreshing ? '刷新中...' : '刷新余额'"
|
:title="refreshTitle"
|
||||||
@click="refresh"
|
@click="refresh"
|
||||||
>
|
>
|
||||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
|
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
|
||||||
@@ -143,6 +143,25 @@ const quotaBarClass = computed(() => {
|
|||||||
return 'bg-green-500 dark:bg-green-600'
|
return 'bg-green-500 dark:bg-green-600'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canRefresh = computed(() => {
|
||||||
|
// 仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
|
||||||
|
const data = balanceData.value
|
||||||
|
if (!data) return false
|
||||||
|
if (data.scriptEnabled === false) return false
|
||||||
|
return !!data.scriptConfigured
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshTitle = computed(() => {
|
||||||
|
if (refreshing.value) return '刷新中...'
|
||||||
|
if (!canRefresh.value) {
|
||||||
|
if (balanceData.value?.scriptEnabled === false) {
|
||||||
|
return '余额脚本功能已禁用'
|
||||||
|
}
|
||||||
|
return '请先配置余额脚本'
|
||||||
|
}
|
||||||
|
return '刷新余额(调用脚本配置的余额 API)'
|
||||||
|
})
|
||||||
|
|
||||||
const primaryText = computed(() => {
|
const primaryText = computed(() => {
|
||||||
if (balanceData.value?.balance?.formattedAmount) {
|
if (balanceData.value?.balance?.formattedAmount) {
|
||||||
return balanceData.value.balance.formattedAmount
|
return balanceData.value.balance.formattedAmount
|
||||||
@@ -178,6 +197,7 @@ const load = async () => {
|
|||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (!props.accountId || !props.platform) return
|
if (!props.accountId || !props.platform) return
|
||||||
if (refreshing.value) return
|
if (refreshing.value) return
|
||||||
|
if (!canRefresh.value) return
|
||||||
|
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
requestError.value = null
|
requestError.value = null
|
||||||
|
|||||||
@@ -143,14 +143,10 @@
|
|||||||
|
|
||||||
<!-- 刷新余额按钮 -->
|
<!-- 刷新余额按钮 -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<el-tooltip
|
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
|
||||||
content="刷新当前页余额(触发查询,失败自动降级)"
|
|
||||||
effect="dark"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<button
|
<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"
|
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"
|
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
|
||||||
@click="refreshVisibleBalances"
|
@click="refreshVisibleBalances"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -2590,9 +2586,26 @@ const closeBalanceScriptModal = () => {
|
|||||||
selectedAccountForScript.value = null
|
selectedAccountForScript.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBalanceScriptSaved = () => {
|
const handleBalanceScriptSaved = async () => {
|
||||||
showToast('余额脚本已保存', 'success')
|
showToast('余额脚本已保存', 'success')
|
||||||
|
const account = selectedAccountForScript.value
|
||||||
closeBalanceScriptModal()
|
closeBalanceScriptModal()
|
||||||
|
|
||||||
|
if (!account?.id || !account?.platform) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新拉取一次余额信息,用于刷新 scriptConfigured 状态(启用“刷新余额”按钮)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/admin/accounts/${account.id}/balance`, {
|
||||||
|
params: { platform: account.platform, queryApi: false }
|
||||||
|
})
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
handleBalanceRefreshed(account.id, res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Failed to reload balance after saving script:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算排序后的账户列表
|
// 计算排序后的账户列表
|
||||||
@@ -2865,6 +2878,25 @@ const paginatedAccounts = computed(() => {
|
|||||||
return sortedAccounts.value.slice(start, end)
|
return sortedAccounts.value.slice(start, end)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canRefreshVisibleBalances = computed(() => {
|
||||||
|
const targets = paginatedAccounts.value
|
||||||
|
if (!Array.isArray(targets) || targets.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets.some((account) => {
|
||||||
|
const info = account?.balanceInfo
|
||||||
|
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshBalanceTooltip = computed(() => {
|
||||||
|
if (accountsLoading.value) return '正在加载账户...'
|
||||||
|
if (refreshingBalances.value) return '刷新中...'
|
||||||
|
if (!canRefreshVisibleBalances.value) return '当前页未配置余额脚本,无法刷新'
|
||||||
|
return '刷新当前页余额(仅对已配置余额脚本的账户生效)'
|
||||||
|
})
|
||||||
|
|
||||||
// 余额刷新成功回调
|
// 余额刷新成功回调
|
||||||
const handleBalanceRefreshed = (accountId, balanceInfo) => {
|
const handleBalanceRefreshed = (accountId, balanceInfo) => {
|
||||||
accounts.value = accounts.value.map((account) => {
|
accounts.value = accounts.value.map((account) => {
|
||||||
@@ -2888,10 +2920,22 @@ const refreshVisibleBalances = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eligibleTargets = targets.filter((account) => {
|
||||||
|
const info = account?.balanceInfo
|
||||||
|
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||||
|
})
|
||||||
|
|
||||||
|
if (eligibleTargets.length === 0) {
|
||||||
|
showToast('当前页没有配置余额脚本的账户', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedCount = targets.length - eligibleTargets.length
|
||||||
|
|
||||||
refreshingBalances.value = true
|
refreshingBalances.value = true
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
targets.map(async (account) => {
|
eligibleTargets.map(async (account) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
|
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
|
||||||
platform: account.platform
|
platform: account.platform
|
||||||
@@ -2913,6 +2957,7 @@ const refreshVisibleBalances = async () => {
|
|||||||
const successCount = results.filter((r) => r.success).length
|
const successCount = results.filter((r) => r.success).length
|
||||||
const failCount = results.length - successCount
|
const failCount = results.length - successCount
|
||||||
|
|
||||||
|
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
|
||||||
if (Object.keys(updatedMap).length > 0) {
|
if (Object.keys(updatedMap).length > 0) {
|
||||||
accounts.value = accounts.value.map((account) => {
|
accounts.value = accounts.value.map((account) => {
|
||||||
const balanceInfo = updatedMap[account.id]
|
const balanceInfo = updatedMap[account.id]
|
||||||
@@ -2922,9 +2967,9 @@ const refreshVisibleBalances = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (failCount === 0) {
|
if (failCount === 0) {
|
||||||
showToast(`成功刷新 ${successCount} 个账户余额`, 'success')
|
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
|
||||||
} else {
|
} else {
|
||||||
showToast(`刷新完成:${successCount} 成功,${failCount} 失败`, 'warning')
|
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
refreshingBalances.value = false
|
refreshingBalances.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user