Merge remote-tracking branch 'f3n9/main' into um-5

This commit is contained in:
Feng Yue
2025-08-31 23:12:46 +08:00
27 changed files with 2515 additions and 271 deletions

View File

@@ -3723,7 +3723,6 @@
"resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.21.3"
},

View File

@@ -807,6 +807,25 @@
</p>
</div>
<!-- Claude 5小时限制自动停止调度选项 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.autoStopOnWarning"
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">
5小时使用量接近限制时自动停止调度
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
当系统检测到账户接近5小时使用限制时自动暂停调度该账户。进入新的时间窗口后会自动恢复调度。
</p>
</div>
</label>
</div>
<!-- 所有平台的优先级设置 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -1318,6 +1337,25 @@
</p>
</div>
<!-- Claude 5小时限制自动停止调度选项编辑模式 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.autoStopOnWarning"
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">
5小时使用量接近限制时自动停止调度
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
当系统检测到账户接近5小时使用限制时自动暂停调度该账户。进入新的时间窗口后会自动恢复调度。
</p>
</div>
</label>
</div>
<!-- 所有平台的优先级设置(编辑模式) -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -1883,6 +1921,7 @@ const form = ref({
description: props.account?.description || '',
accountType: props.account?.accountType || 'shared',
subscriptionType: 'claude_max', // 默认为 Claude Max兼容旧数据
autoStopOnWarning: props.account?.autoStopOnWarning || false, // 5小时限制自动停止调度
groupId: '',
projectId: props.account?.projectId || '',
idToken: '',
@@ -2148,6 +2187,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
// Claude使用claudeAiOauth字段
data.claudeAiOauth = tokenInfo.claudeAiOauth || tokenInfo
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -2299,6 +2339,7 @@ const createAccount = async () => {
scopes: [] // 手动添加没有 scopes
}
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -2537,6 +2578,7 @@ const updateAccount = async () => {
// Claude 官方账号优先级和订阅类型更新
if (props.account.platform === 'claude') {
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
// 更新订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -2912,6 +2954,7 @@ watch(
description: newAccount.description || '',
accountType: newAccount.accountType || 'shared',
subscriptionType: subscriptionType,
autoStopOnWarning: newAccount.autoStopOnWarning || false,
groupId: groupId,
projectId: newAccount.projectId || '',
accessToken: '',

View File

@@ -252,17 +252,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
v-model="form.rateLimitCost"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -275,12 +275,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -336,6 +333,55 @@
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-2">
<div class="flex gap-2">
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制 (可选)</label
@@ -739,11 +785,12 @@ const form = reactive({
batchCount: 10,
name: '',
description: '',
tokenLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
expireDuration: '',
customExpireDate: '',
expiresAt: null,
@@ -985,14 +1032,32 @@ const createApiKey = async () => {
}
}
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续创建',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true
try {
// 准备提交的数据
const baseData = {
description: form.description || undefined,
tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null,
tokenLimit: 0, // 设置为0清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow)
@@ -1001,6 +1066,10 @@ const createApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests)
: null,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: null,
concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit)
@@ -1009,6 +1078,10 @@ const createApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit)
: 0,
weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
expiresAt: form.expiresAt || undefined,
permissions: form.permissions,
tags: form.tags.length > 0 ? form.tags : undefined,

View File

@@ -166,17 +166,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
v-model="form.rateLimitCost"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -189,12 +189,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -250,6 +247,55 @@
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-3">
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制</label
@@ -632,11 +678,13 @@ const unselectedTags = computed(() => {
// 表单数据
const form = reactive({
name: '',
tokenLimit: '',
tokenLimit: '', // 保留用于检测历史数据
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
@@ -702,13 +750,31 @@ const removeTag = (index) => {
// 更新 API Key
const updateApiKey = async () => {
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续保存',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true
try {
// 准备提交的数据
const data = {
tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
tokenLimit: 0, // 清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow)
@@ -717,6 +783,10 @@ const updateApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests)
: 0,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: 0,
concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit)
@@ -725,6 +795,10 @@ const updateApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit)
: 0,
weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
permissions: form.permissions,
tags: form.tags
}
@@ -893,11 +967,22 @@ onMounted(async () => {
}
form.name = props.apiKey.name
// 处理速率限制迁移如果有tokenLimit且没有rateLimitCost提示用户
form.tokenLimit = props.apiKey.tokenLimit || ''
form.rateLimitCost = props.apiKey.rateLimitCost || ''
// 如果有历史tokenLimit但没有rateLimitCost提示用户需要重新设置
if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) {
// 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置)
console.log('检测到历史Token限制请考虑设置费用限制')
}
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
// 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) {

View File

@@ -196,6 +196,8 @@
时间窗口限制
</h5>
<WindowCountdown
:cost-limit="apiKey.rateLimitCost"
:current-cost="apiKey.currentWindowCost"
:current-requests="apiKey.currentWindowRequests"
:current-tokens="apiKey.currentWindowTokens"
label="窗口状态"

View File

@@ -33,6 +33,7 @@
</div>
</div>
<!-- Token限制向后兼容 -->
<div v-if="hasTokenLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">Token</span>
@@ -48,6 +49,23 @@
/>
</div>
</div>
<!-- 费用限制新功能 -->
<div v-if="hasCostLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">费用</span>
<span class="text-gray-600">
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full rounded-full bg-gray-200">
<div
class="h-1 rounded-full transition-all duration-300"
:class="getCostProgressColor()"
:style="{ width: getCostProgress() + '%' }"
/>
</div>
</div>
</div>
<!-- 额外提示信息 -->
@@ -102,6 +120,14 @@ const props = defineProps({
type: Number,
default: 0
},
currentCost: {
type: Number,
default: 0
},
costLimit: {
type: Number,
default: 0
},
showProgress: {
type: Boolean,
default: true
@@ -132,6 +158,7 @@ const windowState = computed(() => {
const hasRequestLimit = computed(() => props.requestLimit > 0)
const hasTokenLimit = computed(() => props.tokenLimit > 0)
const hasCostLimit = computed(() => props.costLimit > 0)
// 方法
const formatTime = (seconds) => {
@@ -196,6 +223,19 @@ const getTokenProgressColor = () => {
return 'bg-purple-500'
}
const getCostProgress = () => {
if (!props.costLimit || props.costLimit === 0) return 0
const percentage = ((props.currentCost || 0) / props.costLimit) * 100
return Math.min(percentage, 100)
}
const getCostProgressColor = () => {
const progress = getCostProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 更新倒计时
const updateCountdown = () => {
if (props.windowEndTime && remainingSeconds.value > 0) {

View File

@@ -45,10 +45,14 @@
<div
v-if="
statsData.limits.rateLimitWindow > 0 &&
(statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)
(statsData.limits.rateLimitRequests > 0 ||
statsData.limits.tokenLimit > 0 ||
statsData.limits.rateLimitCost > 0)
"
>
<WindowCountdown
:cost-limit="statsData.limits.rateLimitCost"
:current-cost="statsData.limits.currentWindowCost"
:current-requests="statsData.limits.currentWindowRequests"
:current-tokens="statsData.limits.currentWindowTokens"
label="时间窗口限制"
@@ -64,7 +68,13 @@
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
<span v-if="statsData.limits.rateLimitCost > 0">
请求次数和费用限制为"或"的关系,任一达到限制即触发限流
</span>
<span v-else-if="statsData.limits.tokenLimit > 0">
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
</span>
<span v-else> 仅限制请求次数 </span>
</div>
</div>

View File

@@ -191,7 +191,39 @@
<th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
会话窗口
<div class="flex items-center gap-2">
<span>会话窗口</span>
<el-tooltip placement="top">
<template #content>
<div class="space-y-2">
<div>会话窗口进度表示5小时窗口的时间进度</div>
<div class="space-y-1 text-xs">
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600"
></div>
<span>正常请求正常处理</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500"
></div>
<span>警告接近限制</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600"
></div>
<span>拒绝达到速率限制</span>
</div>
</div>
</div>
</template>
<i
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
/>
</el-tooltip>
</div>
</th>
<th
class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
@@ -240,12 +272,14 @@
>
<i class="fas fa-share-alt mr-1" />共享
</span>
<!-- 显示所有分组 -->
<span
v-if="account.groupInfo"
v-for="group in account.groupInfos"
:key="group.id"
class="ml-1 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"
:title="`所属分组: ${account.groupInfo.name}`"
:title="`所属分组: ${group.name}`"
>
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
<i class="fas fa-folder mr-1" />{{ group.name }}
</span>
</div>
<div
@@ -393,6 +427,14 @@
>
<i class="fas fa-pause-circle mr-1" />
不可调度
<el-tooltip
v-if="getSchedulableReason(account)"
:content="getSchedulableReason(account)"
effect="dark"
placement="top"
>
<i class="fas fa-question-circle ml-1 cursor-help text-gray-500" />
</el-tooltip>
</span>
<span
v-if="account.status === 'blocked' && account.errorMessage"
@@ -447,15 +489,21 @@
<td class="whitespace-nowrap px-3 py-4 text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500" />
<div class="h-2 w-2 rounded-full bg-blue-500" />
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
>{{ account.usage.daily.requests || 0 }} 次</span
>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-blue-500" />
<div class="h-2 w-2 rounded-full bg-purple-500" />
<span class="text-xs text-gray-600 dark:text-gray-300"
>{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span
>{{ formatNumber(account.usage.daily.allTokens || 0) }}M</span
>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500" />
<span class="text-xs text-gray-600 dark:text-gray-300"
>${{ calculateDailyCost(account) }}</span
>
</div>
<div
@@ -476,10 +524,33 @@
"
class="space-y-2"
>
<!-- 使用统计在顶部 -->
<div
v-if="account.usage && account.usage.sessionWindow"
class="flex items-center gap-3 text-xs"
>
<div class="flex items-center gap-1">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
</span>
</div>
<div class="flex items-center gap-1">
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
<span class="font-medium text-gray-900 dark:text-gray-100">
${{ formatCost(account.usage.sessionWindow.totalCost) }}
</span>
</div>
</div>
<!-- 进度条 -->
<div class="flex items-center gap-2">
<div class="h-2 w-24 rounded-full bg-gray-200">
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
:class="[
'h-2 rounded-full transition-all duration-300',
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus)
]"
:style="{ width: account.sessionWindow.progress + '%' }"
/>
</div>
@@ -487,7 +558,9 @@
{{ account.sessionWindow.progress }}%
</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
<!-- 时间信息 -->
<div class="text-xs text-gray-600 dark:text-gray-400">
<div>
{{
formatSessionWindow(
@@ -498,7 +571,7 @@
</div>
<div
v-if="account.sessionWindow.remainingTime > 0"
class="font-medium text-indigo-600"
class="font-medium text-indigo-600 dark:text-indigo-400"
>
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
</div>
@@ -646,21 +719,44 @@
<div class="mb-3 grid grid-cols-2 gap-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage?.daily?.requests || 0) }} 次
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ formatNumber(account.usage?.daily?.allTokens || 0) }} tokens
</p>
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-blue-500" />
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ account.usage?.daily?.requests || 0 }}
</p>
</div>
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}M
</p>
</div>
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
${{ calculateDailyCost(account) }}
</p>
</div>
</div>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">总使用量</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage?.total?.requests || 0) }} 次
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ formatNumber(account.usage?.total?.allTokens || 0) }} tokens
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">会话窗口</p>
<div v-if="account.usage && account.usage.sessionWindow" class="space-y-1">
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
</p>
</div>
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
${{ formatCost(account.usage.sessionWindow.totalCost) }}
</p>
</div>
</div>
<div v-else class="text-sm font-semibold text-gray-400">-</div>
</div>
</div>
@@ -676,14 +772,27 @@
class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700"
>
<div class="flex items-center justify-between text-xs">
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
<div class="flex items-center gap-1">
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
<el-tooltip
content="会话窗口进度不代表使用量仅表示距离下一个5小时窗口的剩余时间"
placement="top"
>
<i
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600"
/>
</el-tooltip>
</div>
<span class="font-medium text-gray-700 dark:text-gray-200">
{{ account.sessionWindow.progress }}%
</span>
</div>
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
<div
class="h-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
:class="[
'h-full transition-all duration-300',
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus)
]"
:style="{ width: account.sessionWindow.progress + '%' }"
/>
</div>
@@ -824,7 +933,7 @@ const platformFilter = ref('all')
const apiKeysLoaded = ref(false)
const groupsLoaded = ref(false)
const groupMembersLoaded = ref(false)
const accountGroupMap = ref(new Map())
const accountGroupMap = ref(new Map()) // Map<accountId, Array<groupInfo>>
// 下拉选项数据
const sortOptions = ref([
@@ -945,7 +1054,9 @@ const loadAccounts = async (forceReload = false) => {
apiClient.get('/admin/claude-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }) // gemini 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
)
break
case 'claude-console':
@@ -953,7 +1064,9 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // claude 占位
apiClient.get('/admin/claude-console-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }) // gemini 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
)
break
case 'bedrock':
@@ -961,7 +1074,9 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
apiClient.get('/admin/bedrock-accounts', { params }),
Promise.resolve({ success: true, data: [] }) // gemini 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
)
break
case 'gemini':
@@ -969,7 +1084,29 @@ const loadAccounts = async (forceReload = false) => {
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
apiClient.get('/admin/gemini-accounts', { params })
apiClient.get('/admin/gemini-accounts', { params }),
Promise.resolve({ success: true, data: [] }), // openai 占位
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
)
break
case 'openai':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
apiClient.get('/admin/openai-accounts', { params }),
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
)
break
case 'azure_openai':
requests.push(
Promise.resolve({ success: true, data: [] }), // claude 占位
Promise.resolve({ success: true, data: [] }), // claude-console 占位
Promise.resolve({ success: true, data: [] }), // bedrock 占位
Promise.resolve({ success: true, data: [] }), // gemini 占位
Promise.resolve({ success: true, data: [] }), // openai 占位
apiClient.get('/admin/azure-openai-accounts', { params })
)
break
}
@@ -978,8 +1115,8 @@ const loadAccounts = async (forceReload = false) => {
// 使用缓存机制加载 API Keys 和分组数据
await Promise.all([loadApiKeys(forceReload), loadAccountGroups(forceReload)])
// 加载分组成员关系(需要在分组数据加载完成后)
await loadGroupMembers(forceReload)
// 后端账户API已经包含分组信息不需要单独加载分组成员关系
// await loadGroupMembers(forceReload)
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] =
await Promise.all(requests)
@@ -992,9 +1129,8 @@ const loadAccounts = async (forceReload = false) => {
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.claudeAccountId === acc.id
).length
// 检查是否属于某个分组
const groupInfo = accountGroupMap.value.get(acc.id) || null
return { ...acc, platform: 'claude', boundApiKeysCount, groupInfo }
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'claude', boundApiKeysCount }
})
allAccounts.push(...claudeAccounts)
}
@@ -1002,8 +1138,8 @@ const loadAccounts = async (forceReload = false) => {
if (claudeConsoleData.success) {
const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => {
// Claude Console账户暂时不支持直接绑定
const groupInfo = accountGroupMap.value.get(acc.id) || null
return { ...acc, platform: 'claude-console', boundApiKeysCount: 0, groupInfo }
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'claude-console', boundApiKeysCount: 0 }
})
allAccounts.push(...claudeConsoleAccounts)
}
@@ -1011,8 +1147,8 @@ const loadAccounts = async (forceReload = false) => {
if (bedrockData.success) {
const bedrockAccounts = (bedrockData.data || []).map((acc) => {
// Bedrock账户暂时不支持直接绑定
const groupInfo = accountGroupMap.value.get(acc.id) || null
return { ...acc, platform: 'bedrock', boundApiKeysCount: 0, groupInfo }
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 }
})
allAccounts.push(...bedrockAccounts)
}
@@ -1023,8 +1159,8 @@ const loadAccounts = async (forceReload = false) => {
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.geminiAccountId === acc.id
).length
const groupInfo = accountGroupMap.value.get(acc.id) || null
return { ...acc, platform: 'gemini', boundApiKeysCount, groupInfo }
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'gemini', boundApiKeysCount }
})
allAccounts.push(...geminiAccounts)
}
@@ -1034,8 +1170,8 @@ const loadAccounts = async (forceReload = false) => {
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.openaiAccountId === acc.id
).length
const groupInfo = accountGroupMap.value.get(acc.id) || null
return { ...acc, platform: 'openai', boundApiKeysCount, groupInfo }
// 后端已经包含了groupInfos直接使用
return { ...acc, platform: 'openai', boundApiKeysCount }
})
allAccounts.push(...openaiAccounts)
}
@@ -1076,9 +1212,11 @@ const formatNumber = (num) => {
if (num === null || num === undefined) return '0'
const number = Number(num)
if (number >= 1000000) {
return Math.floor(number / 1000000).toLocaleString() + 'M'
return (number / 1000000).toFixed(2)
} else if (number >= 1000) {
return (number / 1000000).toFixed(4)
}
return number.toLocaleString()
return (number / 1000000).toFixed(6)
}
// 格式化最后使用时间
@@ -1131,36 +1269,6 @@ const loadAccountGroups = async (forceReload = false) => {
}
}
// 加载分组成员关系(缓存版本)
const loadGroupMembers = async (forceReload = false) => {
if (!forceReload && groupMembersLoaded.value) {
return // 使用缓存数据
}
try {
// 重置映射
accountGroupMap.value.clear()
// 获取所有分组的成员信息
for (const group of accountGroups.value) {
try {
const membersResponse = await apiClient.get(`/admin/account-groups/${group.id}/members`)
if (membersResponse.success) {
const members = membersResponse.data || []
members.forEach((member) => {
accountGroupMap.value.set(member.id, group)
})
}
} catch (error) {
console.error(`Failed to load members for group ${group.id}:`, error)
}
}
groupMembersLoaded.value = true
} catch (error) {
console.error('Failed to load group members:', error)
}
}
// 清空缓存的函数
const clearCache = () => {
apiKeysLoaded.value = false
@@ -1452,6 +1560,55 @@ const getClaudeAccountType = (account) => {
return 'Claude'
}
// 获取停止调度的原因
const getSchedulableReason = (account) => {
if (account.schedulable !== false) return null
// Claude Console 账户的错误状态
if (account.platform === 'claude-console') {
if (account.status === 'unauthorized') {
return 'API Key无效或已过期401错误'
}
if (account.overloadStatus === 'overloaded') {
return '服务过载529错误'
}
if (account.rateLimitStatus === 'limited') {
return '触发限流429错误'
}
if (account.status === 'blocked' && account.errorMessage) {
return account.errorMessage
}
}
// Claude 官方账户的错误状态
if (account.platform === 'claude') {
if (account.status === 'unauthorized') {
return '认证失败401错误'
}
if (account.status === 'error' && account.errorMessage) {
return account.errorMessage
}
if (account.isRateLimited) {
return '触发限流429错误'
}
// 自动停止调度的原因
if (account.stoppedReason) {
return account.stoppedReason
}
}
// 通用原因
if (account.stoppedReason) {
return account.stoppedReason
}
if (account.errorMessage) {
return account.errorMessage
}
// 默认为手动停止
return '手动停止调度'
}
// 获取账户状态文本
const getAccountStatusText = (account) => {
// 检查是否被封锁
@@ -1537,6 +1694,54 @@ const formatRelativeTime = (dateString) => {
return formatLastUsed(dateString)
}
// 获取会话窗口进度条的样式类
const getSessionProgressBarClass = (status) => {
// 根据状态返回不同的颜色类,包含防御性检查
if (!status) {
// 无状态信息时默认为蓝色
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
}
// 转换为小写进行比较,避免大小写问题
const normalizedStatus = String(status).toLowerCase()
if (normalizedStatus === 'rejected') {
// 被拒绝 - 红色
return 'bg-gradient-to-r from-red-500 to-red-600'
} else if (normalizedStatus === 'allowed_warning') {
// 警告状态 - 橙色/黄色
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
} else {
// 正常状态allowed 或其他) - 蓝色
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
}
}
// 格式化费用显示
const formatCost = (cost) => {
if (!cost || cost === 0) return '0.0000'
if (cost < 0.0001) return cost.toExponential(2)
if (cost < 0.01) return cost.toFixed(6)
if (cost < 1) return cost.toFixed(4)
return cost.toFixed(2)
}
// 计算每日费用(估算,基于平均模型价格)
const calculateDailyCost = (account) => {
if (!account.usage || !account.usage.daily) return '0.0000'
const dailyTokens = account.usage.daily.allTokens || 0
if (dailyTokens === 0) return '0.0000'
// 使用平均价格估算基于Claude 3.5 Sonnet的价格
// 输入: $3/1M tokens, 输出: $15/1M tokens
// 假设平均比例为 输入:输出 = 3:1
const avgPricePerMillion = 3 * 0.75 + 15 * 0.25 // 加权平均价格
const cost = (dailyTokens / 1000000) * avgPricePerMillion
return formatCost(cost)
}
// 切换调度状态
// const toggleDispatch = async (account) => {
// await toggleSchedulable(account)

View File

@@ -466,7 +466,7 @@
<!-- 每日费用限制进度条 -->
<div v-if="key.dailyCostLimit > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">费用限额</span>
<span class="text-gray-500 dark:text-gray-400">每日费用</span>
<span class="text-gray-700 dark:text-gray-300">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{
key.dailyCostLimit.toFixed(2)
@@ -482,9 +482,30 @@
</div>
</div>
<!-- Opus 周费用限制进度条 -->
<div v-if="key.weeklyOpusCostLimit > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">Opus周费用</span>
<span class="text-gray-700 dark:text-gray-300">
${{ (key.weeklyOpusCost || 0).toFixed(2) }} / ${{
key.weeklyOpusCostLimit.toFixed(2)
}}
</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="getWeeklyOpusCostProgressColor(key)"
:style="{ width: getWeeklyOpusCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- 时间窗口限制进度条 -->
<WindowCountdown
v-if="key.rateLimitWindow > 0"
:cost-limit="key.rateLimitCost"
:current-cost="key.currentWindowCost"
:current-requests="key.currentWindowRequests"
:current-tokens="key.currentWindowTokens"
:rate-limit-window="key.rateLimitWindow"
@@ -1059,8 +1080,11 @@
<!-- 移动端时间窗口限制 -->
<WindowCountdown
v-if="
key.rateLimitWindow > 0 && (key.rateLimitRequests > 0 || key.tokenLimit > 0)
key.rateLimitWindow > 0 &&
(key.rateLimitRequests > 0 || key.tokenLimit > 0 || key.rateLimitCost > 0)
"
:cost-limit="key.rateLimitCost"
:current-cost="key.currentWindowCost"
:current-requests="key.currentWindowRequests"
:current-tokens="key.currentWindowTokens"
:rate-limit-window="key.rateLimitWindow"
@@ -2480,6 +2504,21 @@ const getDailyCostProgressColor = (key) => {
return 'bg-green-500'
}
// 获取 Opus 周费用进度
const getWeeklyOpusCostProgress = (key) => {
if (!key.weeklyOpusCostLimit || key.weeklyOpusCostLimit === 0) return 0
const percentage = ((key.weeklyOpusCost || 0) / key.weeklyOpusCostLimit) * 100
return Math.min(percentage, 100)
}
// 获取 Opus 周费用进度条颜色
const getWeeklyOpusCostProgressColor = (key) => {
const progress = getWeeklyOpusCostProgress(key)
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 显示使用详情
const showUsageDetails = (apiKey) => {
selectedApiKeyForDetail.value = apiKey