mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat:单账户配置余额脚本 + 刷新按钮即用脚本”,并去掉独立页面/标签。
具体改动
- 后端
- src/models/redis.js:新增脚本配置存取 account_balance_script:{platform}:{accountId}。
- src/services/accountBalanceService.js:支持脚本查询。若账户有脚本配置且 queryApi=true,调用 balanceScriptService.execute 获取余额/配额,缓存后返回。
- src/routes/admin/accountBalance.js:新增接口
- GET /admin/accounts/:id/balance/script?platform=...
- PUT /admin/accounts/:id/balance/script?platform=...
- POST /admin/accounts/:id/balance/script/test?platform=...
- 前端
- 新增弹窗 AccountBalanceScriptModal,在账户管理页每个账户“余额/配额”下方有“配置余额脚本”按钮,支持填写 baseUrl/apiKey/token/extra/超时/自动间隔、编写脚本、测试、保存。
- 将余额脚本独立路由/标签移除。
- 格式/ lint 已通过(新组件及 AccountsView)。
This commit is contained in:
@@ -1614,6 +1614,28 @@ class RedisClient {
|
|||||||
await this.client.del(key, localKey)
|
await this.client.del(key, localKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧩 账户余额脚本配置
|
||||||
|
async setBalanceScriptConfig(platform, accountId, config) {
|
||||||
|
const key = `account_balance_script:${platform}:${accountId}`
|
||||||
|
await this.client.set(key, JSON.stringify(config || {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalanceScriptConfig(platform, accountId) {
|
||||||
|
const key = `account_balance_script:${platform}:${accountId}`
|
||||||
|
const raw = await this.client.get(key)
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBalanceScriptConfig(platform, accountId) {
|
||||||
|
const key = `account_balance_script:${platform}:${accountId}`
|
||||||
|
return await this.client.del(key)
|
||||||
|
}
|
||||||
|
|
||||||
// 📈 系统统计
|
// 📈 系统统计
|
||||||
async getSystemStats() {
|
async getSystemStats() {
|
||||||
const keys = await Promise.all([
|
const keys = await Promise.all([
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express')
|
|||||||
const { authenticateAdmin } = require('../../middleware/auth')
|
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 router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -127,4 +128,76 @@ router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (re
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 6) 获取/保存/测试余额脚本配置(单账户)
|
||||||
|
router.get('/accounts/:accountId/balance/script', 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await accountBalanceService.redis.getBalanceScriptConfig(valid.platform, accountId)
|
||||||
|
return res.json({ success: true, data: config || null })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取余额脚本配置失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/accounts/:accountId/balance/script', 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = req.body || {}
|
||||||
|
await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload)
|
||||||
|
return res.json({ success: true, data: payload })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('保存余额脚本配置失败', error)
|
||||||
|
return res.status(500).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/accounts/:accountId/balance/script/test', 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = req.body || {}
|
||||||
|
const scriptBody = payload.scriptBody
|
||||||
|
if (!scriptBody) {
|
||||||
|
return res.status(400).json({ success: false, error: '脚本内容不能为空' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await balanceScriptService.execute({
|
||||||
|
scriptBody,
|
||||||
|
timeoutSeconds: payload.timeoutSeconds || 10,
|
||||||
|
variables: {
|
||||||
|
baseUrl: payload.baseUrl || '',
|
||||||
|
apiKey: payload.apiKey || '',
|
||||||
|
token: payload.token || '',
|
||||||
|
accountId,
|
||||||
|
platform: valid.platform,
|
||||||
|
extra: payload.extra || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('测试余额脚本失败', error)
|
||||||
|
return res.status(400).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
41
src/routes/admin/balanceScripts.js
Normal file
41
src/routes/admin/balanceScripts.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const balanceScriptService = require('../../services/balanceScriptService')
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 获取全部脚本配置列表
|
||||||
|
router.get('/balance-scripts', authenticateAdmin, (req, res) => {
|
||||||
|
const items = balanceScriptService.listConfigs()
|
||||||
|
return res.json({ success: true, data: items })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取单个脚本配置
|
||||||
|
router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||||
|
const { name } = req.params
|
||||||
|
const config = balanceScriptService.getConfig(name || 'default')
|
||||||
|
return res.json({ success: true, data: config })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 保存脚本配置
|
||||||
|
router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params
|
||||||
|
const saved = balanceScriptService.saveConfig(name || 'default', req.body || {})
|
||||||
|
return res.json({ success: true, data: saved })
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 测试脚本(不落库)
|
||||||
|
router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params
|
||||||
|
const result = await balanceScriptService.testScript(name || 'default', req.body || {})
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -22,6 +22,7 @@ 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')
|
||||||
@@ -38,6 +39,7 @@ 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)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
|
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 redis = require('../models/redis')
|
||||||
|
|
||||||
class AccountBalanceService {
|
class AccountBalanceService {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
@@ -321,25 +323,32 @@ class AccountBalanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 强制查询:调用 Provider,失败自动降级到本地统计
|
// 强制查询:调用 Provider,失败自动降级到本地统计
|
||||||
const provider = this.providers.get(platform)
|
const scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId)
|
||||||
if (!provider) {
|
let providerResult
|
||||||
return this._buildResponse(
|
|
||||||
{
|
if (scriptConfig && scriptConfig.scriptBody) {
|
||||||
status: 'error',
|
providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform)
|
||||||
errorMessage: `不支持的平台: ${platform}`,
|
} else {
|
||||||
balance: quotaFromLocal.balance,
|
const provider = this.providers.get(platform)
|
||||||
currency: quotaFromLocal.currency || 'USD',
|
if (!provider) {
|
||||||
quota: quotaFromLocal.quota,
|
return this._buildResponse(
|
||||||
statistics: localStatistics,
|
{
|
||||||
lastRefreshAt: new Date().toISOString()
|
status: 'error',
|
||||||
},
|
errorMessage: `不支持的平台: ${platform}`,
|
||||||
accountId,
|
balance: quotaFromLocal.balance,
|
||||||
platform,
|
currency: quotaFromLocal.currency || 'USD',
|
||||||
'local'
|
quota: quotaFromLocal.quota,
|
||||||
)
|
statistics: localStatistics,
|
||||||
|
lastRefreshAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
'local'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
providerResult = await this._getBalanceFromProvider(provider, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerResult = await this._getBalanceFromProvider(provider, account)
|
|
||||||
await this.redis.setAccountBalance(platform, accountId, providerResult, this.CACHE_TTL_SECONDS)
|
await this.redis.setAccountBalance(platform, accountId, providerResult, this.CACHE_TTL_SECONDS)
|
||||||
|
|
||||||
const source = providerResult.status === 'success' ? 'api' : 'local'
|
const source = providerResult.status === 'success' ? 'api' : 'local'
|
||||||
@@ -360,6 +369,46 @@ class AccountBalanceService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _getBalanceFromScript(scriptConfig, accountId, platform) {
|
||||||
|
try {
|
||||||
|
const result = await balanceScriptService.execute({
|
||||||
|
scriptBody: scriptConfig.scriptBody,
|
||||||
|
timeoutSeconds: scriptConfig.timeoutSeconds || 10,
|
||||||
|
variables: {
|
||||||
|
baseUrl: scriptConfig.baseUrl || '',
|
||||||
|
apiKey: scriptConfig.apiKey || '',
|
||||||
|
token: scriptConfig.token || '',
|
||||||
|
accountId,
|
||||||
|
platform,
|
||||||
|
extra: scriptConfig.extra || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapped = result?.mapped || {}
|
||||||
|
return {
|
||||||
|
status: mapped.status || 'error',
|
||||||
|
balance: typeof mapped.balance === 'number' ? mapped.balance : null,
|
||||||
|
currency: mapped.currency || 'USD',
|
||||||
|
quota: mapped.quota || null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: mapped.rawData || result?.response?.data || null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: mapped.errorMessage || ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: null,
|
||||||
|
lastRefreshAt: new Date().toISOString(),
|
||||||
|
errorMessage: error.message || '脚本执行失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async _getBalanceFromProvider(provider, account) {
|
async _getBalanceFromProvider(provider, account) {
|
||||||
try {
|
try {
|
||||||
const result = await provider.queryBalance(account)
|
const result = await provider.queryBalance(account)
|
||||||
|
|||||||
241
src/services/balanceScriptService.js
Normal file
241
src/services/balanceScriptService.js
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const vm = require('vm')
|
||||||
|
const axios = require('axios')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可配置脚本余额查询服务
|
||||||
|
* - 存储位置: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
|
||||||
|
* - scriptBody: string
|
||||||
|
* - variables: Record<string,string>
|
||||||
|
* - timeoutSeconds: number
|
||||||
|
*/
|
||||||
|
async execute(options = {}) {
|
||||||
|
const scriptBody = options.scriptBody?.trim()
|
||||||
|
if (!scriptBody) {
|
||||||
|
throw new Error('脚本内容为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000)
|
||||||
|
const sandbox = {
|
||||||
|
console,
|
||||||
|
Math,
|
||||||
|
Date
|
||||||
|
}
|
||||||
|
|
||||||
|
let scriptResult
|
||||||
|
try {
|
||||||
|
const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})`
|
||||||
|
const script = new vm.Script(wrapped, { timeout: timeoutMs })
|
||||||
|
scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs })
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`脚本解析失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scriptResult || typeof scriptResult !== 'object') {
|
||||||
|
throw new Error('脚本返回格式无效(需返回 { request, extractor })')
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = options.variables || {}
|
||||||
|
const request = this.applyTemplates(scriptResult.request || {}, variables)
|
||||||
|
const extractor = scriptResult.extractor
|
||||||
|
|
||||||
|
if (!request.url) {
|
||||||
|
throw new Error('脚本 request.url 不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
url: request.url,
|
||||||
|
method: (request.method || 'GET').toUpperCase(),
|
||||||
|
headers: request.headers || {},
|
||||||
|
timeout: timeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params) {
|
||||||
|
axiosConfig.params = request.params
|
||||||
|
}
|
||||||
|
if (request.body || request.data) {
|
||||||
|
axiosConfig.data = request.body || request.data
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpResponse = null
|
||||||
|
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 responseData = httpResponse?.data
|
||||||
|
let extracted = {}
|
||||||
|
if (typeof extractor === 'function') {
|
||||||
|
try {
|
||||||
|
extracted = extractor(responseData) || {}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`extractor 执行失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = this.mapExtractorResult(extracted, responseData)
|
||||||
|
return {
|
||||||
|
mapped,
|
||||||
|
extracted,
|
||||||
|
response: {
|
||||||
|
status: httpResponse?.status,
|
||||||
|
headers: httpResponse?.headers,
|
||||||
|
data: responseData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTemplates(value, variables) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.replace(/{{(\w+)}}/g, (_, key) => {
|
||||||
|
const trimmed = key.trim()
|
||||||
|
return variables[trimmed] !== undefined ? String(variables[trimmed]) : ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => this.applyTemplates(item, variables))
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const result = {}
|
||||||
|
Object.keys(value).forEach((k) => {
|
||||||
|
result[k] = this.applyTemplates(value[k], variables)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
mapExtractorResult(result = {}, responseData) {
|
||||||
|
const isValid = result.isValid !== false
|
||||||
|
const remaining = Number(result.remaining)
|
||||||
|
const total = Number(result.total)
|
||||||
|
const used = Number(result.used)
|
||||||
|
const currency = result.unit || 'USD'
|
||||||
|
|
||||||
|
const quota =
|
||||||
|
Number.isFinite(total) || Number.isFinite(used)
|
||||||
|
? {
|
||||||
|
total: Number.isFinite(total) ? total : null,
|
||||||
|
used: Number.isFinite(used) ? used : null,
|
||||||
|
remaining: Number.isFinite(remaining) ? remaining : null,
|
||||||
|
percentage:
|
||||||
|
Number.isFinite(total) && total > 0 && Number.isFinite(used)
|
||||||
|
? (used / total) * 100
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: isValid ? 'success' : 'error',
|
||||||
|
errorMessage: isValid ? '' : result.invalidMessage || '套餐无效',
|
||||||
|
balance: Number.isFinite(remaining) ? remaining : null,
|
||||||
|
currency,
|
||||||
|
quota,
|
||||||
|
planName: result.planName || null,
|
||||||
|
extra: result.extra || null,
|
||||||
|
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()
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="show"
|
||||||
|
:title="`配置余额脚本 - ${account?.name || ''}`"
|
||||||
|
width="720px"
|
||||||
|
@close="emitClose"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||||
|
<input v-model="form.apiKey" class="input-text" placeholder="access token / key" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>请求地址(baseUrl)</label
|
||||||
|
>
|
||||||
|
<input v-model="form.baseUrl" class="input-text" placeholder="https://api.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">Token(可选)</label>
|
||||||
|
<input v-model="form.token" class="input-text" placeholder="Bearer token" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>额外参数 (extra / userId)</label
|
||||||
|
>
|
||||||
|
<input v-model="form.extra" class="input-text" placeholder="用户ID等" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">超时时间(秒)</label>
|
||||||
|
<input v-model.number="form.timeoutSeconds" class="input-text" min="1" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>自动查询间隔(分钟)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="form.autoIntervalMinutes"
|
||||||
|
class="input-text"
|
||||||
|
min="0"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">0 表示仅手动刷新</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 md:col-span-2">
|
||||||
|
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{ '}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{ '}'
|
||||||
|
}}{{ '}' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">提取器代码</div>
|
||||||
|
<button
|
||||||
|
class="rounded bg-gray-200 px-2 py-1 text-xs dark:bg-gray-700"
|
||||||
|
@click="applyPreset"
|
||||||
|
>
|
||||||
|
使用示例
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.scriptBody"
|
||||||
|
class="min-h-[260px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
extractor 可返回:isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||||
|
</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 class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">测试结果</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'rounded px-2 py-0.5 text-xs',
|
||||||
|
testResult.mapped?.status === 'success'
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ testResult.mapped?.status || 'unknown' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||||
|
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||||
|
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||||
|
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||||
|
错误: {{ testResult.mapped.errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||||
|
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||||
|
formatJson(testResult.extracted)
|
||||||
|
}}</pre>
|
||||||
|
</details>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看原始响应</summary>
|
||||||
|
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||||
|
formatJson(testResult.response)
|
||||||
|
}}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
account: { type: Object, default: () => ({}) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const testResult = ref(null)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
token: '',
|
||||||
|
extra: '',
|
||||||
|
timeoutSeconds: 10,
|
||||||
|
autoIntervalMinutes: 0,
|
||||||
|
scriptBody: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const presetScript = `({
|
||||||
|
request: {
|
||||||
|
url: "{{baseUrl}}/api/user/self",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer {{apiKey}}",
|
||||||
|
"New-Api-User": "{{extra}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extractor: function (response) {
|
||||||
|
if (response && response.success && response.data) {
|
||||||
|
const quota = response.data.quota || 0;
|
||||||
|
const used = response.data.used_quota || 0;
|
||||||
|
return {
|
||||||
|
planName: response.data.group || "默认套餐",
|
||||||
|
remaining: quota / 500000,
|
||||||
|
used: used / 500000,
|
||||||
|
total: (quota + used) / 500000,
|
||||||
|
unit: "USD"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
invalidMessage: (response && response.message) || "查询失败"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})`
|
||||||
|
|
||||||
|
const emitClose = () => emit('close')
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
if (!props.account?.id || !props.account?.platform) return
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`
|
||||||
|
)
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
Object.assign(form, res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('加载脚本配置失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
if (!props.account?.id || !props.account?.platform) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await apiClient.put(
|
||||||
|
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`,
|
||||||
|
{ ...form }
|
||||||
|
)
|
||||||
|
showToast('已保存', 'success')
|
||||||
|
emit('saved')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '保存失败', 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testScript = async () => {
|
||||||
|
if (!props.account?.id || !props.account?.platform) return
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(
|
||||||
|
`/admin/accounts/${props.account.id}/balance/script/test?platform=${props.account.platform}`,
|
||||||
|
{ ...form }
|
||||||
|
)
|
||||||
|
if (res?.success) {
|
||||||
|
testResult.value = res.data
|
||||||
|
showToast('测试完成', 'success')
|
||||||
|
} else {
|
||||||
|
showToast(res?.error || '测试失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '测试失败', 'error')
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreset = () => {
|
||||||
|
form.scriptBody = presetScript
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayAmount = (val) => {
|
||||||
|
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||||
|
return Number(val).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatJson = (data) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2)
|
||||||
|
} catch (error) {
|
||||||
|
return String(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
applyPreset()
|
||||||
|
loadConfig()
|
||||||
|
testResult.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -804,6 +804,14 @@
|
|||||||
@error="(error) => handleBalanceError(account.id, error)"
|
@error="(error) => handleBalanceError(account.id, error)"
|
||||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||||
/>
|
/>
|
||||||
|
<div class="mt-1 text-xs">
|
||||||
|
<button
|
||||||
|
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||||
|
@click="openBalanceScriptModal(account)"
|
||||||
|
>
|
||||||
|
配置余额脚本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</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">
|
||||||
@@ -1475,6 +1483,14 @@
|
|||||||
@error="(error) => handleBalanceError(account.id, error)"
|
@error="(error) => handleBalanceError(account.id, error)"
|
||||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||||
/>
|
/>
|
||||||
|
<div class="mt-1 text-xs">
|
||||||
|
<button
|
||||||
|
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||||
|
@click="openBalanceScriptModal(account)"
|
||||||
|
>
|
||||||
|
配置余额脚本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 状态信息 -->
|
<!-- 状态信息 -->
|
||||||
@@ -1958,6 +1974,13 @@
|
|||||||
@saved="handleScheduledTestSaved"
|
@saved="handleScheduledTestSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AccountBalanceScriptModal
|
||||||
|
:account="selectedAccountForScript"
|
||||||
|
:show="showBalanceScriptModal"
|
||||||
|
@close="closeBalanceScriptModal"
|
||||||
|
@saved="handleBalanceScriptSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 账户统计弹窗 -->
|
<!-- 账户统计弹窗 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showAccountStatsModal"
|
v-model="showAccountStatsModal"
|
||||||
@@ -2115,6 +2138,7 @@ 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'
|
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
|
||||||
|
import AccountBalanceScriptModal from '@/components/accounts/AccountBalanceScriptModal.vue'
|
||||||
|
|
||||||
// 使用确认弹窗
|
// 使用确认弹窗
|
||||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||||
@@ -2552,6 +2576,25 @@ const handleScheduledTestSaved = () => {
|
|||||||
showToast('定时测试配置已保存', 'success')
|
showToast('定时测试配置已保存', 'success')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 余额脚本配置
|
||||||
|
const showBalanceScriptModal = ref(false)
|
||||||
|
const selectedAccountForScript = ref(null)
|
||||||
|
|
||||||
|
const openBalanceScriptModal = (account) => {
|
||||||
|
selectedAccountForScript.value = account
|
||||||
|
showBalanceScriptModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBalanceScriptModal = () => {
|
||||||
|
showBalanceScriptModal.value = false
|
||||||
|
selectedAccountForScript.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBalanceScriptSaved = () => {
|
||||||
|
showToast('余额脚本已保存', 'success')
|
||||||
|
closeBalanceScriptModal()
|
||||||
|
}
|
||||||
|
|
||||||
// 计算排序后的账户列表
|
// 计算排序后的账户列表
|
||||||
const sortedAccounts = computed(() => {
|
const sortedAccounts = computed(() => {
|
||||||
let sourceAccounts = accounts.value
|
let sourceAccounts = accounts.value
|
||||||
|
|||||||
312
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
312
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row">
|
||||||
|
<div class="glass-strong flex-1 rounded-2xl p-4 shadow-lg">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">脚本余额配置</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
使用自定义脚本 + 模板变量适配任意余额接口
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
@click="loadConfig"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="saveConfig"
|
||||||
|
>
|
||||||
|
<span v-if="saving">保存中...</span>
|
||||||
|
<span v-else>保存配置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||||
|
<input v-model="form.apiKey" class="input-text" placeholder="sk-xxxx" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
请求地址(baseUrl)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.baseUrl"
|
||||||
|
class="input-text"
|
||||||
|
placeholder="https://api.example.com"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>Token(可选)</label
|
||||||
|
>
|
||||||
|
<input v-model="form.token" class="input-text" placeholder="Bearer token" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>超时时间(秒)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="form.timeoutSeconds"
|
||||||
|
class="input-text"
|
||||||
|
min="1"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
自动查询间隔(分钟)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.autoIntervalMinutes"
|
||||||
|
class="input-text"
|
||||||
|
min="0"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">模板变量</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{
|
||||||
|
'}'
|
||||||
|
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{
|
||||||
|
'}'
|
||||||
|
}}{{ '}' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-strong w-full max-w-xl rounded-2xl p-4 shadow-lg">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">测试脚本</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
填入账号上下文(可选),调试 extractor 输出
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||||
|
:disabled="testing"
|
||||||
|
@click="testScript"
|
||||||
|
>
|
||||||
|
<span v-if="testing">测试中...</span>
|
||||||
|
<span v-else>测试脚本</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">平台</label>
|
||||||
|
<input v-model="testForm.platform" class="input-text" placeholder="例如 claude" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">账号ID</label>
|
||||||
|
<input v-model="testForm.accountId" class="input-text" placeholder="账号标识,可选" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>额外参数 (extra)</label
|
||||||
|
>
|
||||||
|
<input v-model="testForm.extra" class="input-text" placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="testResult" class="mt-4 space-y-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800/60">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-gray-100">测试结果</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'rounded px-2 py-0.5 text-xs',
|
||||||
|
testResult.mapped?.status === 'success'
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||||
|
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ testResult.mapped?.status || 'unknown' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||||
|
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||||
|
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||||
|
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||||
|
错误: {{ testResult.mapped.errorMessage }}
|
||||||
|
</div>
|
||||||
|
<div v-if="testResult.mapped?.quota">
|
||||||
|
配额: {{ JSON.stringify(testResult.mapped.quota) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||||
|
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||||
|
>{{ formatJson(testResult.extracted) }}
|
||||||
|
</pre
|
||||||
|
>
|
||||||
|
</details>
|
||||||
|
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer">查看原始响应</summary>
|
||||||
|
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||||
|
>{{ formatJson(testResult.response) }}
|
||||||
|
</pre
|
||||||
|
>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-strong rounded-2xl p-4 shadow-lg">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">提取器代码</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
返回对象需包含 request、extractor;支持模板变量替换
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
@click="applyPreset"
|
||||||
|
>
|
||||||
|
使用示例模板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.scriptBody"
|
||||||
|
class="min-h-[320px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
extractor
|
||||||
|
返回字段(可选):isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
token: '',
|
||||||
|
timeoutSeconds: 10,
|
||||||
|
autoIntervalMinutes: 0,
|
||||||
|
scriptBody: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const testForm = reactive({
|
||||||
|
platform: '',
|
||||||
|
accountId: '',
|
||||||
|
extra: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const testResult = ref(null)
|
||||||
|
|
||||||
|
const presetScript = `({
|
||||||
|
request: {
|
||||||
|
url: "{{baseUrl}}/user/balance",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": "Bearer {{apiKey}}",
|
||||||
|
"User-Agent": "cc-switch/1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extractor: function(response) {
|
||||||
|
return {
|
||||||
|
isValid: response.is_active || true,
|
||||||
|
remaining: response.balance,
|
||||||
|
unit: "USD",
|
||||||
|
planName: response.plan || "默认套餐"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})`
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/admin/balance-scripts/default')
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
Object.assign(form, res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('加载配置失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = { ...form }
|
||||||
|
await apiClient.put('/admin/balance-scripts/default', payload)
|
||||||
|
showToast('配置已保存', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '保存失败', 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testScript = async () => {
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
...testForm,
|
||||||
|
scriptBody: form.scriptBody
|
||||||
|
}
|
||||||
|
const res = await apiClient.post('/admin/balance-scripts/default/test', payload)
|
||||||
|
if (res?.success) {
|
||||||
|
testResult.value = res.data
|
||||||
|
showToast('测试完成', 'success')
|
||||||
|
} else {
|
||||||
|
showToast(res?.error || '测试失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '测试失败', 'error')
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreset = () => {
|
||||||
|
form.scriptBody = presetScript
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayAmount = (val) => {
|
||||||
|
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||||
|
return Number(val).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatJson = (data) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2)
|
||||||
|
} catch (error) {
|
||||||
|
return String(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
applyPreset()
|
||||||
|
loadConfig()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user