Files
claude-relay-service/web/admin-spa/src/views/BalanceScriptsView.vue
atoz03 ce496ed9e6 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)。
2025-12-20 01:18:49 +08:00

313 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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">
返回对象需包含 requestextractor支持模板变量替换
</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
返回字段可选isValidinvalidMessageremainingunitplanNametotalusedextra
</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>