Merge branch 'main' into antigravity

This commit is contained in:
Wesley Liddick
2025-12-26 00:56:27 -05:00
committed by GitHub
50 changed files with 6223 additions and 185 deletions

View File

@@ -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 可返回isValidinvalidMessageremainingunitplanNametotalusedextra
</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>

View File

@@ -1662,6 +1662,47 @@
</label>
</div>
<!-- Claude 账户级串行队列开关 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.serialQueueEnabled"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
启用账户级串行队列
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
</p>
</div>
</label>
</div>
<!-- 拦截预热请求开关Claude 和 Claude Console -->
<div
v-if="form.platform === 'claude' || form.platform === 'claude-console'"
class="mt-4"
>
<label class="flex items-start">
<input
v-model="form.interceptWarmup"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
拦截预热请求
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
启用后对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
@@ -2647,6 +2688,44 @@
</label>
</div>
<!-- Claude 账户级串行队列开关(编辑模式) -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.serialQueueEnabled"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
启用账户级串行队列
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
</p>
</div>
</label>
</div>
<!-- 拦截预热请求开关Claude 和 Claude Console 编辑模式) -->
<div v-if="form.platform === 'claude' || form.platform === 'claude-console'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.interceptWarmup"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
拦截预热请求
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
启用后对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置(编辑模式) -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
@@ -3982,6 +4061,9 @@ const form = ref({
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
interceptWarmup:
props.account?.interceptWarmup === true || props.account?.interceptWarmup === 'true', // 拦截预热请求
groupId: '',
groupIds: [],
projectId: props.account?.projectId || '',
@@ -4572,9 +4654,11 @@ const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
claudeAiOauth: claudeOauthPayload,
priority: form.value.priority || 50,
autoStopOnWarning: form.value.autoStopOnWarning || false,
interceptWarmup: form.value.interceptWarmup || false,
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
useUnifiedClientId: form.value.useUnifiedClientId || false,
unifiedClientId: clientId,
maxConcurrency: form.value.serialQueueEnabled ? 1 : 0,
subscriptionInfo: {
accountType: form.value.subscriptionType || 'claude_max',
hasClaudeMax: form.value.subscriptionType === 'claude_max',
@@ -4712,6 +4796,7 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -5040,6 +5125,7 @@ const createAccount = async () => {
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -5131,6 +5217,7 @@ const createAccount = async () => {
// 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection
data.interceptWarmup = !!form.value.interceptWarmup
}
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
@@ -5431,9 +5518,11 @@ const updateAccount = async () => {
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
data.interceptWarmup = form.value.interceptWarmup || false
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 更新订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -5466,6 +5555,8 @@ const updateAccount = async () => {
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection
// 拦截预热请求
data.interceptWarmup = !!form.value.interceptWarmup
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -6034,9 +6125,12 @@ watch(
accountType: newAccount.accountType || 'shared',
subscriptionType: subscriptionType,
autoStopOnWarning: newAccount.autoStopOnWarning || false,
interceptWarmup:
newAccount.interceptWarmup === true || newAccount.interceptWarmup === 'true',
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
useUnifiedClientId: newAccount.useUnifiedClientId || false,
unifiedClientId: newAccount.unifiedClientId || '',
serialQueueEnabled: (newAccount.maxConcurrency || 0) > 0,
groupId: groupId,
groupIds: [],
projectId: newAccount.projectId || '',

View File

@@ -0,0 +1,402 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
>
<div class="absolute inset-0" @click="handleClose" />
<div
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
>
<!-- 顶部栏 -->
<div
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 text-white shadow-lg"
>
<i class="fas fa-clock" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">定时测试配置</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ account?.name || '未知账户' }}
</p>
</div>
</div>
<button
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
:disabled="saving"
@click="handleClose"
>
<i class="fas fa-times text-sm" />
</button>
</div>
<!-- 内容区域 -->
<div class="px-5 py-4">
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<i class="fas fa-spinner fa-spin mr-2 text-blue-500" />
<span class="text-gray-500 dark:text-gray-400">加载配置中...</span>
</div>
<template v-else>
<!-- 启用开关 -->
<div class="mb-5 flex items-center justify-between">
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">启用定时测试</p>
<p class="text-xs text-gray-500 dark:text-gray-400">按计划自动测试账户连通性</p>
</div>
<button
:class="[
'relative h-6 w-11 rounded-full transition-colors duration-200',
config.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
]"
@click="config.enabled = !config.enabled"
>
<span
:class="[
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
config.enabled ? 'left-5' : 'left-0.5'
]"
/>
</button>
</div>
<!-- Cron 表达式配置 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Cron 表达式
</label>
<input
v-model="config.cronExpression"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="!config.enabled"
placeholder="0 8 * * *"
type="text"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
格式: (: "0 8 * * *" = 每天8:00)
</p>
</div>
<!-- 快捷选项 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
快捷设置
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in cronPresets"
:key="preset.value"
:class="[
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
config.cronExpression === preset.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
!config.enabled && 'cursor-not-allowed opacity-50'
]"
:disabled="!config.enabled"
@click="config.cronExpression = preset.value"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- 测试模型选择 -->
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
测试模型
</label>
<input
v-model="config.model"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="!config.enabled"
placeholder="claude-sonnet-4-5-20250929"
type="text"
/>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="modelOption in modelOptions"
:key="modelOption.value"
:class="[
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
config.model === modelOption.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
!config.enabled && 'cursor-not-allowed opacity-50'
]"
:disabled="!config.enabled"
@click="config.model = modelOption.value"
>
{{ modelOption.label }}
</button>
</div>
</div>
<!-- 测试历史 -->
<div v-if="testHistory.length > 0" class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
最近测试记录
</label>
<div
class="max-h-40 space-y-2 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
>
<div
v-for="(record, index) in testHistory"
:key="index"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<i
:class="[
'fas',
record.success
? 'fa-check-circle text-green-500'
: 'fa-times-circle text-red-500'
]"
/>
<span class="text-gray-600 dark:text-gray-400">
{{ formatTimestamp(record.timestamp) }}
</span>
</div>
<span v-if="record.latencyMs" class="text-gray-500 dark:text-gray-500">
{{ record.latencyMs }}ms
</span>
<span
v-else-if="record.error"
class="max-w-[150px] truncate text-red-500"
:title="record.error"
>
{{ record.error }}
</span>
</div>
</div>
</div>
<!-- 无历史记录 -->
<div
v-else
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
>
<i class="fas fa-history mb-2 text-2xl text-gray-300 dark:text-gray-600" />
<p>暂无测试记录</p>
</div>
</template>
</div>
<!-- 底部操作栏 -->
<div
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
>
<button
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:disabled="saving"
@click="handleClose"
>
取消
</button>
<button
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
saving
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
]"
:disabled="saving || loading"
@click="saveConfig"
>
<i :class="['fas', saving ? 'fa-spinner fa-spin' : 'fa-save']" />
{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, watch } from 'vue'
import { API_PREFIX } from '@/config/api'
import { showToast } from '@/utils/toast'
const props = defineProps({
show: {
type: Boolean,
default: false
},
account: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'saved'])
// 状态
const loading = ref(false)
const saving = ref(false)
const config = ref({
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
})
const testHistory = ref([])
// Cron 预设选项
const cronPresets = [
{ label: '每天 8:00', value: '0 8 * * *' },
{ label: '每天 12:00', value: '0 12 * * *' },
{ label: '每天 18:00', value: '0 18 * * *' },
{ label: '每6小时', value: '0 */6 * * *' },
{ label: '每12小时', value: '0 */12 * * *' },
{ label: '工作日 9:00', value: '0 9 * * 1-5' }
]
// 模型选项
const modelOptions = [
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' }
]
// 格式化时间戳
function formatTimestamp(timestamp) {
if (!timestamp) return '未知'
const date = new Date(timestamp)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 加载配置
async function loadConfig() {
if (!props.account) return
loading.value = true
try {
const authToken = localStorage.getItem('authToken')
const platform = props.account.platform
// 根据平台获取配置端点
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
} else {
// 其他平台暂不支持
loading.value = false
return
}
// 获取配置
const configRes = await fetch(endpoint, {
headers: {
Authorization: authToken ? `Bearer ${authToken}` : ''
}
})
if (configRes.ok) {
const data = await configRes.json()
if (data.success && data.data?.config) {
config.value = {
enabled: data.data.config.enabled || false,
cronExpression: data.data.config.cronExpression || '0 8 * * *',
model: data.data.config.model || 'claude-sonnet-4-5-20250929'
}
}
}
// 获取测试历史
const historyEndpoint = endpoint.replace('/test-config', '/test-history')
const historyRes = await fetch(historyEndpoint, {
headers: {
Authorization: authToken ? `Bearer ${authToken}` : ''
}
})
if (historyRes.ok) {
const historyData = await historyRes.json()
if (historyData.success && historyData.data?.history) {
testHistory.value = historyData.data.history
}
}
} catch (err) {
showToast('加载配置失败: ' + err.message, 'error')
} finally {
loading.value = false
}
}
// 保存配置
async function saveConfig() {
if (!props.account) return
saving.value = true
try {
const authToken = localStorage.getItem('authToken')
const platform = props.account.platform
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
} else {
saving.value = false
return
}
const res = await fetch(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
},
body: JSON.stringify({
enabled: config.value.enabled,
cronExpression: config.value.cronExpression,
model: config.value.model
})
})
if (res.ok) {
showToast('配置已保存', 'success')
emit('saved')
handleClose()
} else {
const errorData = await res.json().catch(() => ({}))
showToast(errorData.message || '保存失败', 'error')
}
} catch (err) {
showToast('保存失败: ' + err.message, 'error')
} finally {
saving.value = false
}
}
// 关闭模态框
function handleClose() {
if (saving.value) return
emit('close')
}
// 监听 show 变化,加载配置
watch(
() => props.show,
(newVal) => {
if (newVal) {
config.value = {
enabled: false,
cronExpression: '0 8 * * *',
model: 'claude-sonnet-4-5-20250929'
}
testHistory.value = []
loadConfig()
}
}
)
</script>

View 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>

View File

@@ -579,55 +579,46 @@
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label
>
<div class="flex gap-4">
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="all"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="claude"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="gemini"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="openai"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span>
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="droid"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Droid</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务
不选择任何服务表示允许访问全部服务
</p>
</div>
@@ -662,7 +653,7 @@
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
@@ -676,7 +667,7 @@
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
@@ -690,7 +681,7 @@
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
@@ -704,7 +695,7 @@
v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="[]"
placeholder="请选择Bedrock账号"
platform="bedrock"
@@ -718,7 +709,7 @@
v-model="form.droidAccountId"
:accounts="localAccounts.droid"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
:groups="localAccounts.droidGroups"
placeholder="请选择Droid账号"
platform="droid"
@@ -966,7 +957,7 @@ const form = reactive({
expirationMode: 'fixed', // 过期模式fixed(固定) 或 activation(激活)
activationDays: 30, // 激活后有效天数
activationUnit: 'days', // 激活时间单位hours 或 days
permissions: 'all',
permissions: [], // 数组格式,空数组表示全部服务
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',

View File

@@ -412,55 +412,46 @@
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label
>
<div class="flex gap-4">
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="all"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="claude"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="gemini"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="openai"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span>
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="droid"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Droid</span>
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务
不选择任何服务表示允许访问全部服务
</p>
</div>
@@ -495,7 +486,7 @@
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
@@ -509,7 +500,7 @@
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
@@ -523,7 +514,7 @@
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
@@ -537,7 +528,7 @@
v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="[]"
placeholder="请选择Bedrock账号"
platform="bedrock"
@@ -551,7 +542,7 @@
v-model="form.droidAccountId"
:accounts="localAccounts.droid"
default-option-text="使用共享账号池"
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
:groups="localAccounts.droidGroups"
placeholder="请选择Droid账号"
platform="droid"
@@ -800,7 +791,7 @@ const form = reactive({
dailyCostLimit: '',
totalCostLimit: '',
weeklyOpusCostLimit: '',
permissions: 'all',
permissions: [], // 数组格式,空数组表示全部服务
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',
@@ -1241,7 +1232,17 @@ onMounted(async () => {
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.totalCostLimit = props.apiKey.totalCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
const perms = props.apiKey.permissions
if (Array.isArray(perms)) {
form.permissions = perms
} else if (perms === 'all' || !perms) {
form.permissions = []
} else if (typeof perms === 'string') {
form.permissions = [perms]
} else {
form.permissions = []
}
// 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) {
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`

View File

@@ -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 -->
@@ -1238,6 +1282,15 @@
<i class="fas fa-vial" />
<span class="ml-1">测试</span>
</button>
<button
v-if="canTestAccount(account)"
class="rounded bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
title="定时测试配置"
@click="openScheduledTestModal(account)"
>
<i class="fas fa-clock" />
<span class="ml-1">定时</span>
</button>
<button
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
title="编辑账户"
@@ -1416,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">
<!-- 会话窗口 -->
@@ -1707,6 +1780,15 @@
测试
</button>
<button
v-if="canTestAccount(account)"
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-600 transition-colors hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-800/50"
@click="openScheduledTestModal(account)"
>
<i class="fas fa-clock" />
定时
</button>
<button
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
@click="editAccount(account)"
@@ -1880,6 +1962,21 @@
@close="closeAccountTestModal"
/>
<!-- 定时测试配置弹窗 -->
<AccountScheduledTestModal
:account="scheduledTestAccount"
:show="showScheduledTestModal"
@close="closeScheduledTestModal"
@saved="handleScheduledTestSaved"
/>
<AccountBalanceScriptModal
:account="selectedAccountForScript"
:show="showBalanceScriptModal"
@close="closeBalanceScriptModal"
@saved="handleBalanceScriptSaved"
/>
<!-- 账户统计弹窗 -->
<el-dialog
v-model="showAccountStatsModal"
@@ -2032,9 +2129,12 @@ import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
import AccountTestModal from '@/components/accounts/AccountTestModal.vue'
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
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()
@@ -2042,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([]) // 保留用于其他功能(如删除账户时显示绑定信息)
@@ -2099,6 +2200,10 @@ const expiryEditModalRef = ref(null)
const showAccountTestModal = ref(false)
const testingAccount = ref(null)
// 定时测试配置弹窗状态
const showScheduledTestModal = ref(false)
const scheduledTestAccount = ref(null)
// 账户统计弹窗状态
const showAccountStatsModal = ref(false)
@@ -2365,6 +2470,13 @@ const getAccountActions = (account) => {
color: 'blue',
handler: () => openAccountTestModal(account)
})
actions.push({
key: 'scheduled-test',
label: '定时测试',
icon: 'fa-clock',
color: 'amber',
handler: () => openScheduledTestModal(account)
})
}
// 删除
@@ -2441,6 +2553,61 @@ const closeAccountTestModal = () => {
testingAccount.value = null
}
// 定时测试配置相关函数
const openScheduledTestModal = (account) => {
if (!canTestAccount(account)) {
showToast('该账户类型暂不支持定时测试', 'warning')
return
}
scheduledTestAccount.value = account
showScheduledTestModal.value = true
}
const closeScheduledTestModal = () => {
showScheduledTestModal.value = false
scheduledTestAccount.value = null
}
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
@@ -2711,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) =>
@@ -2761,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
@@ -2953,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 {

View 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">
返回对象需包含 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>

View File

@@ -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
}