mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge pull request #825 from atoz03/feat/account-quota [skip ci]
Feat:account quota
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:append-to-body="true"
|
||||
class="balance-script-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="true"
|
||||
:model-value="show"
|
||||
:title="`配置余额脚本 - ${account?.name || ''}`"
|
||||
top="5vh"
|
||||
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 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>
|
||||
|
||||
<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>
|
||||
</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 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 form = reactive({
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
extra: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
scriptBody: ''
|
||||
})
|
||||
|
||||
const buildDefaultForm = () => ({
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
extra: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
// 默认给出示例脚本,字段保持清空,避免“上一个账户的配置污染当前账户”
|
||||
scriptBody: presetScript
|
||||
})
|
||||
|
||||
const emitClose = () => emit('close')
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, buildDefaultForm())
|
||||
testResult.value = null
|
||||
saving.value = false
|
||||
testing.value = false
|
||||
}
|
||||
|
||||
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) {
|
||||
resetForm()
|
||||
loadConfig()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
@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>
|
||||
281
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
281
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="min-w-[200px] space-y-1">
|
||||
<div v-if="loading" class="flex items-center gap-2">
|
||||
<i class="fas fa-spinner fa-spin text-gray-400 dark:text-gray-500"></i>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="requestError" class="flex items-center gap-2">
|
||||
<i class="fas fa-exclamation-circle text-red-500"></i>
|
||||
<span class="text-xs text-red-600 dark:text-red-400">{{ requestError }}</span>
|
||||
<button
|
||||
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
|
||||
:disabled="refreshing"
|
||||
@click="reload"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="balanceData" class="space-y-1">
|
||||
<div v-if="balanceData.status === 'error' && balanceData.error" class="text-xs text-red-500">
|
||||
{{ balanceData.error }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
class="fas"
|
||||
:class="
|
||||
balanceData.balance
|
||||
? 'fa-wallet text-green-600 dark:text-green-400'
|
||||
: 'fa-chart-line text-gray-500 dark:text-gray-400'
|
||||
"
|
||||
></i>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ primaryText }}
|
||||
</span>
|
||||
<span class="rounded px-1.5 py-0.5 text-xs" :class="sourceClass">
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!hideRefresh"
|
||||
class="text-xs text-gray-500 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
:disabled="refreshing || !canRefresh"
|
||||
:title="refreshTitle"
|
||||
@click="refresh"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 配额(如适用) -->
|
||||
<div v-if="quotaInfo" class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>已用: {{ formatNumber(quotaInfo.used) }}</span>
|
||||
<span>剩余: {{ formatNumber(quotaInfo.remaining) }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="quotaBarClass"
|
||||
:style="{ width: `${Math.min(100, quotaInfo.percentage)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ quotaInfo.percentage.toFixed(1) }}% 已使用
|
||||
</span>
|
||||
<span v-if="quotaInfo.resetAt" class="text-gray-400 dark:text-gray-500">
|
||||
重置: {{ formatResetTime(quotaInfo.resetAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="balanceData.quota?.unlimited" class="flex items-center gap-2">
|
||||
<i class="fas fa-infinity text-blue-500 dark:text-blue-400"></i>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">无限制</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="balanceData.cacheExpiresAt && balanceData.source === 'cache'"
|
||||
class="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
缓存至: {{ formatCacheExpiry(balanceData.cacheExpiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-xs text-gray-400 dark:text-gray-500">暂无余额数据</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
accountId: { type: String, required: true },
|
||||
platform: { type: String, required: true },
|
||||
initialBalance: { type: Object, default: null },
|
||||
hideRefresh: { type: Boolean, default: false },
|
||||
autoLoad: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refreshed', 'error'])
|
||||
|
||||
const balanceData = ref(props.initialBalance)
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const requestError = ref(null)
|
||||
|
||||
const sourceClass = computed(() => {
|
||||
const source = balanceData.value?.source
|
||||
return {
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300': source === 'api',
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300': source === 'cache',
|
||||
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': source === 'local'
|
||||
}
|
||||
})
|
||||
|
||||
const sourceLabel = computed(() => {
|
||||
const source = balanceData.value?.source
|
||||
return { api: 'API', cache: '缓存', local: '本地' }[source] || '未知'
|
||||
})
|
||||
|
||||
const quotaInfo = computed(() => {
|
||||
const quota = balanceData.value?.quota
|
||||
if (!quota || quota.unlimited) return null
|
||||
if (typeof quota.percentage !== 'number' || !Number.isFinite(quota.percentage)) return null
|
||||
return {
|
||||
used: quota.used ?? 0,
|
||||
remaining: quota.remaining ?? 0,
|
||||
percentage: quota.percentage,
|
||||
resetAt: quota.resetAt || null
|
||||
}
|
||||
})
|
||||
|
||||
const quotaBarClass = computed(() => {
|
||||
const percentage = quotaInfo.value?.percentage || 0
|
||||
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
|
||||
if (percentage >= 70) return 'bg-yellow-500 dark:bg-yellow-600'
|
||||
return 'bg-green-500 dark:bg-green-600'
|
||||
})
|
||||
|
||||
const 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(() => {
|
||||
if (balanceData.value?.balance?.formattedAmount) {
|
||||
return balanceData.value.balance.formattedAmount
|
||||
}
|
||||
const dailyCost = Number(balanceData.value?.statistics?.dailyCost || 0)
|
||||
return `今日成本 ${formatCurrency(dailyCost)}`
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
if (!props.autoLoad) return
|
||||
if (!props.accountId || !props.platform) return
|
||||
|
||||
loading.value = true
|
||||
requestError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
|
||||
params: { platform: props.platform, queryApi: false }
|
||||
})
|
||||
if (response?.success) {
|
||||
balanceData.value = response.data
|
||||
} else {
|
||||
requestError.value = response?.error || '加载失败'
|
||||
}
|
||||
} catch (error) {
|
||||
requestError.value = error.message || '网络错误'
|
||||
emit('error', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
if (!props.accountId || !props.platform) return
|
||||
if (refreshing.value) return
|
||||
if (!canRefresh.value) return
|
||||
|
||||
refreshing.value = true
|
||||
requestError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/accounts/${props.accountId}/balance/refresh`, {
|
||||
platform: props.platform
|
||||
})
|
||||
if (response?.success) {
|
||||
balanceData.value = response.data
|
||||
emit('refreshed', response.data)
|
||||
} else {
|
||||
requestError.value = response?.error || '刷新失败'
|
||||
}
|
||||
} catch (error) {
|
||||
requestError.value = error.message || '网络错误'
|
||||
emit('error', error)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
await load()
|
||||
}
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num === Infinity) return '∞'
|
||||
const value = Number(num)
|
||||
if (!Number.isFinite(value)) return 'N/A'
|
||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
const value = Number(amount)
|
||||
if (!Number.isFinite(value)) return '$0.00'
|
||||
if (value >= 1) return `$${value.toFixed(2)}`
|
||||
if (value >= 0.01) return `$${value.toFixed(3)}`
|
||||
return `$${value.toFixed(6)}`
|
||||
}
|
||||
|
||||
const formatResetTime = (isoString) => {
|
||||
const date = new Date(isoString)
|
||||
const now = new Date()
|
||||
const diff = date.getTime() - now.getTime()
|
||||
if (!Number.isFinite(diff)) return '未知'
|
||||
if (diff < 0) return '已过期'
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainMinutes = minutes % 60
|
||||
if (hours >= 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}天后`
|
||||
}
|
||||
return `${hours}小时${remainMinutes}分钟`
|
||||
}
|
||||
|
||||
const formatCacheExpiry = (isoString) => {
|
||||
const date = new Date(isoString)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialBalance,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
balanceData.value = newVal
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.initialBalance) {
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ refresh, reload })
|
||||
</script>
|
||||
@@ -141,6 +141,28 @@
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 刷新余额按钮 -->
|
||||
<div class="relative">
|
||||
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
|
||||
<button
|
||||
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
|
||||
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
|
||||
@click="refreshVisibleBalances"
|
||||
>
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||
></div>
|
||||
<i
|
||||
:class="[
|
||||
'fas relative text-blue-500',
|
||||
refreshingBalances ? 'fa-spinner fa-spin' : 'fa-wallet'
|
||||
]"
|
||||
/>
|
||||
<span class="relative">刷新余额</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 选择/取消选择按钮 -->
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@@ -263,6 +285,11 @@
|
||||
>
|
||||
今日使用
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[220px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
余额/配额
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
@@ -765,6 +792,23 @@
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<BalanceDisplay
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
配置余额脚本
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||||
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||||
@@ -1425,6 +1469,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 余额/配额 -->
|
||||
<div class="mb-3">
|
||||
<p class="mb-1 text-xs text-gray-500 dark:text-gray-400">余额/配额</p>
|
||||
<BalanceDisplay
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
配置余额脚本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态信息 -->
|
||||
<div class="mb-3 space-y-2">
|
||||
<!-- 会话窗口 -->
|
||||
@@ -1906,6 +1970,13 @@
|
||||
@saved="handleScheduledTestSaved"
|
||||
/>
|
||||
|
||||
<AccountBalanceScriptModal
|
||||
:account="selectedAccountForScript"
|
||||
:show="showBalanceScriptModal"
|
||||
@close="closeBalanceScriptModal"
|
||||
@saved="handleBalanceScriptSaved"
|
||||
/>
|
||||
|
||||
<!-- 账户统计弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showAccountStatsModal"
|
||||
@@ -2062,6 +2133,8 @@ import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTes
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
|
||||
import AccountBalanceScriptModal from '@/components/accounts/AccountBalanceScriptModal.vue'
|
||||
|
||||
// 使用确认弹窗
|
||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||
@@ -2069,6 +2142,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
|
||||
// 数据状态
|
||||
const accounts = ref([])
|
||||
const accountsLoading = ref(false)
|
||||
const refreshingBalances = ref(false)
|
||||
const accountsSortBy = ref('name')
|
||||
const accountsSortOrder = ref('asc')
|
||||
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
||||
@@ -2498,6 +2572,42 @@ const handleScheduledTestSaved = () => {
|
||||
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 = async () => {
|
||||
showToast('余额脚本已保存', 'success')
|
||||
const account = selectedAccountForScript.value
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算排序后的账户列表
|
||||
const sortedAccounts = computed(() => {
|
||||
let sourceAccounts = accounts.value
|
||||
@@ -2768,6 +2878,104 @@ const paginatedAccounts = computed(() => {
|
||||
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) => {
|
||||
accounts.value = accounts.value.map((account) => {
|
||||
if (account.id !== accountId) return account
|
||||
return { ...account, balanceInfo }
|
||||
})
|
||||
}
|
||||
|
||||
// 余额请求错误回调(仅提示,不中断页面)
|
||||
const handleBalanceError = (_accountId, error) => {
|
||||
const message = error?.message || '余额查询失败'
|
||||
showToast(message, 'error')
|
||||
}
|
||||
|
||||
// 批量刷新当前页余额(触发查询)
|
||||
const refreshVisibleBalances = async () => {
|
||||
if (refreshingBalances.value) return
|
||||
|
||||
const targets = paginatedAccounts.value
|
||||
if (!targets || targets.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
eligibleTargets.map(async (account) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
|
||||
platform: account.platform
|
||||
})
|
||||
return { id: account.id, success: !!response?.success, data: response?.data || null }
|
||||
} catch (error) {
|
||||
return { id: account.id, success: false, error: error?.message || '刷新失败' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const updatedMap = results.reduce((map, item) => {
|
||||
if (item.success && item.data) {
|
||||
map[item.id] = item.data
|
||||
}
|
||||
return map
|
||||
}, {})
|
||||
|
||||
const successCount = results.filter((r) => r.success).length
|
||||
const failCount = results.length - successCount
|
||||
|
||||
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
|
||||
if (Object.keys(updatedMap).length > 0) {
|
||||
accounts.value = accounts.value.map((account) => {
|
||||
const balanceInfo = updatedMap[account.id]
|
||||
if (!balanceInfo) return account
|
||||
return { ...account, balanceInfo }
|
||||
})
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
|
||||
} else {
|
||||
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
|
||||
}
|
||||
} finally {
|
||||
refreshingBalances.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSelectAllState = () => {
|
||||
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
||||
const selectedInCurrentPage = currentIds.filter((id) =>
|
||||
@@ -2818,6 +3026,54 @@ const cleanupSelectedAccounts = () => {
|
||||
updateSelectAllState()
|
||||
}
|
||||
|
||||
// 异步加载余额缓存(按平台批量拉取,避免逐行请求)
|
||||
const loadBalanceCacheForAccounts = async () => {
|
||||
const current = accounts.value
|
||||
if (!Array.isArray(current) || current.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const platforms = Array.from(new Set(current.map((acc) => acc.platform).filter(Boolean)))
|
||||
if (platforms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const responses = await Promise.all(
|
||||
platforms.map(async (platform) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/accounts/balance/platform/${platform}`, {
|
||||
params: { queryApi: false }
|
||||
})
|
||||
return { platform, success: !!res?.success, data: res?.data || [] }
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load balance cache for ${platform}:`, error)
|
||||
return { platform, success: false, data: [] }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const balanceMap = responses.reduce((map, item) => {
|
||||
if (!item.success) return map
|
||||
const list = Array.isArray(item.data) ? item.data : []
|
||||
list.forEach((entry) => {
|
||||
const accountId = entry?.data?.accountId
|
||||
if (accountId) {
|
||||
map[accountId] = entry.data
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, {})
|
||||
|
||||
if (Object.keys(balanceMap).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.value = accounts.value.map((account) => ({
|
||||
...account,
|
||||
balanceInfo: balanceMap[account.id] || account.balanceInfo || null
|
||||
}))
|
||||
}
|
||||
|
||||
// 加载账户列表
|
||||
const loadAccounts = async (forceReload = false) => {
|
||||
accountsLoading.value = true
|
||||
@@ -3010,6 +3266,11 @@ const loadAccounts = async (forceReload = false) => {
|
||||
console.debug('Claude usage loading failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// 异步加载余额缓存(按平台批量)
|
||||
loadBalanceCacheForAccounts().catch((err) => {
|
||||
console.debug('Balance cache loading failed:', err)
|
||||
})
|
||||
} catch (error) {
|
||||
showToast('加载账户失败', 'error')
|
||||
} finally {
|
||||
|
||||
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>
|
||||
@@ -196,6 +196,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户余额/配额汇总 -->
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
账户余额/配额
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
|
||||
{{ formatCurrencyUsd(balanceSummary.totalBalance || 0) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
低余额: {{ balanceSummary.lowBalanceCount || 0 }} | 总成本:
|
||||
{{ formatCurrencyUsd(balanceSummary.totalCost || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-green-600">
|
||||
<i class="fas fa-wallet" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
更新时间: {{ formatLastUpdate(balanceSummaryUpdatedAt) }}
|
||||
</p>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500"
|
||||
:disabled="loadingBalanceSummary"
|
||||
@click="loadBalanceSummary"
|
||||
>
|
||||
<i :class="['fas', loadingBalanceSummary ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">低余额账户</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ lowBalanceAccounts.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingBalanceSummary"
|
||||
class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
正在加载...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="lowBalanceAccounts.length === 0"
|
||||
class="py-6 text-center text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
全部正常
|
||||
</div>
|
||||
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="account in lowBalanceAccounts"
|
||||
:key="account.accountId"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900/60 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ account.name || account.accountId }}
|
||||
</div>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ getBalancePlatformLabel(account.platform) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span v-if="account.balance">余额: {{ account.balance.formattedAmount }}</span>
|
||||
<span v-else
|
||||
>今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="account.quota && typeof account.quota.percentage === 'number'" class="mt-2">
|
||||
<div
|
||||
class="mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span>配额使用</span>
|
||||
<span class="text-red-600 dark:text-red-400">
|
||||
{{ account.quota.percentage.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-2 rounded-full bg-red-500"
|
||||
:style="{ width: `${Math.min(100, account.quota.percentage)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token统计和性能指标 -->
|
||||
<div
|
||||
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
|
||||
@@ -681,6 +780,8 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import Chart from 'chart.js/auto'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
@@ -732,6 +833,97 @@ const accountGroupOptions = [
|
||||
|
||||
const accountTrendUpdating = ref(false)
|
||||
|
||||
// 余额/配额汇总
|
||||
const balanceSummary = ref({
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
platforms: {}
|
||||
})
|
||||
const loadingBalanceSummary = ref(false)
|
||||
const balanceSummaryUpdatedAt = ref(null)
|
||||
|
||||
const getBalancePlatformLabel = (platform) => {
|
||||
const map = {
|
||||
claude: 'Claude',
|
||||
'claude-console': 'Claude Console',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
openai: 'OpenAI',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
azure_openai: 'Azure OpenAI',
|
||||
bedrock: 'Bedrock',
|
||||
droid: 'Droid',
|
||||
ccr: 'CCR'
|
||||
}
|
||||
return map[platform] || platform
|
||||
}
|
||||
|
||||
const lowBalanceAccounts = computed(() => {
|
||||
const result = []
|
||||
const platforms = balanceSummary.value?.platforms || {}
|
||||
|
||||
Object.entries(platforms).forEach(([platform, data]) => {
|
||||
const list = Array.isArray(data?.accounts) ? data.accounts : []
|
||||
list.forEach((entry) => {
|
||||
const accountData = entry?.data
|
||||
if (!accountData) return
|
||||
|
||||
const amount = accountData.balance?.amount
|
||||
const percentage = accountData.quota?.percentage
|
||||
|
||||
const isLowBalance = typeof amount === 'number' && amount < 10
|
||||
const isHighUsage = typeof percentage === 'number' && percentage > 90
|
||||
|
||||
if (isLowBalance || isHighUsage) {
|
||||
result.push({
|
||||
...accountData,
|
||||
name: entry?.name || accountData.accountId,
|
||||
platform: accountData.platform || platform
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const formatCurrencyUsd = (amount) => {
|
||||
const value = Number(amount)
|
||||
if (!Number.isFinite(value)) return '$0.00'
|
||||
if (value >= 1) return `$${value.toFixed(2)}`
|
||||
if (value >= 0.01) return `$${value.toFixed(3)}`
|
||||
return `$${value.toFixed(6)}`
|
||||
}
|
||||
|
||||
const formatLastUpdate = (isoString) => {
|
||||
if (!isoString) return '未知'
|
||||
const date = new Date(isoString)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const loadBalanceSummary = async () => {
|
||||
loadingBalanceSummary.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/admin/accounts/balance/summary')
|
||||
if (response?.success) {
|
||||
balanceSummary.value = response.data || {
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
platforms: {}
|
||||
}
|
||||
balanceSummaryUpdatedAt.value = new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('加载余额汇总失败:', error)
|
||||
showToast('加载余额汇总失败', 'error')
|
||||
} finally {
|
||||
loadingBalanceSummary.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 自动刷新相关
|
||||
const autoRefreshEnabled = ref(false)
|
||||
const autoRefreshInterval = ref(30) // 秒
|
||||
@@ -1488,7 +1680,7 @@ async function refreshAllData() {
|
||||
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
await Promise.all([loadDashboardData(), refreshChartsData()])
|
||||
await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()])
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user