From f6f4b5cfece2d88433e22b4e0eca157334b65739 Mon Sep 17 00:00:00 2001 From: atoz03 Date: Sun, 14 Dec 2025 13:43:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=BD=99=E9=A2=9D=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E9=A9=B1=E5=8A=A8=E7=9A=84=E4=BD=99=E9=A2=9D/?= =?UTF-8?q?=E9=85=8D=E9=A2=9D=E5=88=B7=E6=96=B0=E4=B8=8E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E4=BD=93=E9=AA=8C=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 明确刷新语义:仅脚本启用且已配置时触发远程查询;未配置时前端禁用并提示\n- 新增余额脚本安全开关 BALANCE_SCRIPT_ENABLED(默认开启),脚本测试接口受控\n- Redis 增加单账户脚本配置存取,响应透出 scriptEnabled/scriptConfigured 供 UI 判定\n- accountBalanceService:本地统计汇总改用 SCAN+pipeline,避免 KEYS;仅缓存远程成功结果,避免失败/降级覆盖有效缓存\n- 管理端体验:刷新按钮按配置状态灰置;脚本弹窗内容可滚动、底部操作栏固定,并 append-to-body 使弹窗跟随当前视窗 --- config/config.example.js | 8 ++ src/models/redis.js | 8 +- src/routes/admin/accountBalance.js | 9 +- src/routes/admin/index.js | 2 - src/services/accountBalanceService.js | 114 ++++++++++----- src/services/balanceScriptService.js | 132 ++++-------------- src/utils/featureFlags.js | 44 ++++++ tests/accountBalanceService.test.js | 82 ++++++++++- .../accounts/AccountBalanceScriptModal.vue | 35 ++++- .../components/accounts/BalanceDisplay.vue | 26 +++- web/admin-spa/src/views/AccountsView.vue | 65 +++++++-- 11 files changed, 358 insertions(+), 167 deletions(-) create mode 100644 src/utils/featureFlags.js diff --git a/config/config.example.js b/config/config.example.js index 9cf26002..e5e0c340 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -205,6 +205,14 @@ const config = { hotReload: process.env.HOT_RELOAD === 'true' }, + // 💰 账户余额相关配置 + accountBalance: { + // 是否允许执行自定义余额脚本(安全开关) + // 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启 + // 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false + enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false' + }, + // 📬 用户消息队列配置 // 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算 userMessageQueue: { diff --git a/src/models/redis.js b/src/models/redis.js index 3adc51d1..be90c749 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1615,15 +1615,17 @@ class RedisClient { } // 🧩 账户余额脚本配置 - async setBalanceScriptConfig(platform, accountId, config) { + async setBalanceScriptConfig(platform, accountId, scriptConfig) { 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) { const key = `account_balance_script:${platform}:${accountId}` const raw = await this.client.get(key) - if (!raw) return null + if (!raw) { + return null + } try { return JSON.parse(raw) } catch (error) { diff --git a/src/routes/admin/accountBalance.js b/src/routes/admin/accountBalance.js index 2f55c850..5669cf40 100644 --- a/src/routes/admin/accountBalance.js +++ b/src/routes/admin/accountBalance.js @@ -3,6 +3,7 @@ const { authenticateAdmin } = require('../../middleware/auth') const logger = require('../../utils/logger') const accountBalanceService = require('../../services/accountBalanceService') const balanceScriptService = require('../../services/balanceScriptService') +const { isBalanceScriptEnabled } = require('../../utils/featureFlags') 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 // Body: { platform: 'xxx' } 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 }) } + if (!isBalanceScriptEnabled()) { + return res + .status(403) + .json({ success: false, error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)' }) + } + const payload = req.body || {} const scriptBody = payload.scriptBody if (!scriptBody) { diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index f5cb2268..f7deafd6 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -22,7 +22,6 @@ const droidAccountsRoutes = require('./droidAccounts') const dashboardRoutes = require('./dashboard') const usageStatsRoutes = require('./usageStats') const accountBalanceRoutes = require('./accountBalance') -const balanceScriptsRoutes = require('./balanceScripts') const systemRoutes = require('./system') const concurrencyRoutes = require('./concurrency') const claudeRelayConfigRoutes = require('./claudeRelayConfig') @@ -39,7 +38,6 @@ router.use('/', droidAccountsRoutes) router.use('/', dashboardRoutes) router.use('/', usageStatsRoutes) router.use('/', accountBalanceRoutes) -router.use('/', balanceScriptsRoutes) router.use('/', systemRoutes) router.use('/', concurrencyRoutes) router.use('/', claudeRelayConfigRoutes) diff --git a/src/services/accountBalanceService.js b/src/services/accountBalanceService.js index 478e2d47..3265c4b8 100644 --- a/src/services/accountBalanceService.js +++ b/src/services/accountBalanceService.js @@ -2,6 +2,7 @@ const redis = require('../models/redis') const balanceScriptService = require('./balanceScriptService') const logger = require('../utils/logger') const CostCalculator = require('../utils/costCalculator') +const { isBalanceScriptEnabled } = require('../utils/featureFlags') class AccountBalanceService { constructor(options = {}) { @@ -277,6 +278,20 @@ class AccountBalanceService { 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 localStatistics = localBalance.statistics || {} @@ -300,7 +315,8 @@ class AccountBalanceService { accountId, platform, 'cache', - cached.ttlSeconds + cached.ttlSeconds, + scriptMeta ) } } @@ -317,15 +333,16 @@ class AccountBalanceService { }, accountId, platform, - 'local' + 'local', + null, + scriptMeta ) } - // 强制查询:调用 Provider,失败自动降级到本地统计 - const scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId) + // 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计 let providerResult - if (scriptConfig && scriptConfig.scriptBody) { + if (scriptEnabled && scriptConfigured) { providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform) } else { const provider = this.providers.get(platform) @@ -342,15 +359,28 @@ class AccountBalanceService { }, accountId, platform, - 'local' + 'local', + null, + scriptMeta ) } 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( { @@ -364,7 +394,9 @@ class AccountBalanceService { }, accountId, platform, - source + source, + null, + scriptMeta ) } @@ -507,35 +539,50 @@ class AccountBalanceService { 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) { + let cursor = '0' + const scanCount = 200 + 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 } - const parts = String(keys[i]).split(':') - const model = parts[4] || 'unknown' + const pipeline = client.pipeline() + keys.forEach((key) => pipeline.hgetall(key)) + const results = await pipeline.exec() - 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) + 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 } - const costResult = CostCalculator.calculateCost(usage, model) - totalCost += costResult.costs.total || 0 - } + if (iterations >= maxIterations) { + this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`) + break + } + } while (cursor !== '0') return totalCost } catch (error) { @@ -610,7 +657,7 @@ class AccountBalanceService { 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 amount = typeof balanceData.balance === 'number' ? balanceData.balance : null @@ -642,7 +689,8 @@ class AccountBalanceService { lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(), cacheExpiresAt, status: balanceData.status || 'success', - error: balanceData.errorMessage || null + error: balanceData.errorMessage || null, + ...(extraData && typeof extraData === 'object' ? extraData : {}) } } } diff --git a/src/services/balanceScriptService.js b/src/services/balanceScriptService.js index 36a996b9..5bf06801 100644 --- a/src/services/balanceScriptService.js +++ b/src/services/balanceScriptService.js @@ -1,81 +1,13 @@ -const fs = require('fs') -const path = require('path') const vm = require('vm') const axios = require('axios') -const logger = require('../utils/logger') +const { isBalanceScriptEnabled } = require('../utils/featureFlags') /** - * 可配置脚本余额查询服务 - * - 存储位置:data/balanceScripts.json + * 可配置脚本余额查询执行器 * - 脚本格式:({ request: {...}, extractor: function(response){...} }) * - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}} */ 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 @@ -84,6 +16,12 @@ class BalanceScriptService { * - timeoutSeconds: number */ 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() if (!scriptBody) { throw new Error('脚本内容为空') @@ -99,7 +37,7 @@ class BalanceScriptService { let scriptResult try { 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 }) } catch (error) { throw new Error(`脚本解析失败: ${error.message}`) @@ -111,12 +49,16 @@ class BalanceScriptService { const variables = options.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 不能为空') } + if (typeof extractor !== 'function') { + throw new Error('脚本 extractor 必须是函数') + } + const axiosConfig = { url: request.url, method: (request.method || 'GET').toUpperCase(), @@ -131,23 +73,24 @@ class BalanceScriptService { axiosConfig.data = request.body || request.data } - let httpResponse = null + let httpResponse try { httpResponse = await axios(axiosConfig) } catch (error) { - const status = error.response?.status - const data = error.response?.data - throw new Error(`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`) + const { response } = error || {} + const { status, data } = response || {} + throw new Error( + `请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}` + ) } const responseData = httpResponse?.data + let extracted = {} - if (typeof extractor === 'function') { - try { - extracted = extractor(responseData) || {} - } catch (error) { - throw new Error(`extractor 执行失败: ${error.message}`) - } + try { + extracted = extractor(responseData) || {} + } catch (error) { + throw new Error(`extractor 执行失败: ${error.message}`) } const mapped = this.mapExtractorResult(extracted, responseData) @@ -213,29 +156,6 @@ class BalanceScriptService { 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() diff --git a/src/utils/featureFlags.js b/src/utils/featureFlags.js new file mode 100644 index 00000000..35802d55 --- /dev/null +++ b/src/utils/featureFlags.js @@ -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 +} diff --git a/tests/accountBalanceService.test.js b/tests/accountBalanceService.test.js index 9510b9b3..f3b60d78 100644 --- a/tests/accountBalanceService.test.js +++ b/tests/accountBalanceService.test.js @@ -11,6 +11,16 @@ const accountBalanceServiceModule = require('../src/services/accountBalanceServi const { AccountBalanceService } = accountBalanceServiceModule 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 = { debug: jest.fn(), info: jest.fn(), @@ -24,6 +34,7 @@ describe('AccountBalanceService', () => { getAccountBalance: jest.fn().mockResolvedValue(null), setAccountBalance: jest.fn().mockResolvedValue(undefined), deleteAccountBalance: jest.fn().mockResolvedValue(undefined), + getBalanceScriptConfig: jest.fn().mockResolvedValue(null), getAccountUsageStats: jest.fn().mockResolvedValue({ total: { requests: 10 }, daily: { requests: 2, cost: 20 }, @@ -87,7 +98,7 @@ describe('AccountBalanceService', () => { 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 service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) @@ -106,12 +117,78 @@ describe('AccountBalanceService', () => { useCache: false }) - expect(mockRedis.setAccountBalance).toHaveBeenCalled() + expect(mockRedis.setAccountBalance).not.toHaveBeenCalled() expect(result.data.source).toBe('local') expect(result.data.status).toBe('error') 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 () => { const mockRedis = buildMockRedis() const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) @@ -139,4 +216,3 @@ describe('AccountBalanceService', () => { expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1) }) }) - diff --git a/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue index ef60d447..13fd97ce 100644 --- a/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue +++ b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue @@ -1,7 +1,12 @@ @@ -256,6 +263,22 @@ watch(