mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 完善账户管理和仪表盘功能
- 修改使用记录API路由路径为 /dashboard/usage-records - 增加对更多账户类型的支持(Bedrock、Azure、Droid、CCR等) - 修复Codex模型识别逻辑,避免 gpt-5-codex 系列被错误归一化 - 在账户管理页面添加状态过滤器(正常/异常) - 在账户管理页面添加限流时间过滤器(≤1h/5h/12h/1d) - 增加账户统计汇总弹窗,按平台分类展示 - 完善仪表盘使用记录展示功能,支持分页加载 - 将 logs1/ 目录添加到 .gitignore 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,6 +26,7 @@ redis_data/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
logs1/
|
||||
*.log
|
||||
startup.log
|
||||
app.log
|
||||
|
||||
@@ -705,7 +705,7 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
|
||||
// 📊 获取最近的使用记录
|
||||
router.get('/usage-records', authenticateAdmin, async (req, res) => {
|
||||
router.get('/dashboard/usage-records', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { limit = 100, offset = 0 } = req.query
|
||||
const limitNum = Math.min(parseInt(limit) || 100, 500) // 最多500条
|
||||
@@ -774,8 +774,44 @@ router.get('/usage-records', authenticateAdmin, async (req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 其他平台账户...
|
||||
accountNameMap[accountId] = accountId // 降级显示ID
|
||||
const bedrockAcc = await redis.getBedrockAccount(accountId)
|
||||
if (bedrockAcc && bedrockAcc.name) {
|
||||
accountNameMap[accountId] = bedrockAcc.name
|
||||
return
|
||||
}
|
||||
|
||||
const azureAcc = await redis.getAzureOpenaiAccount(accountId)
|
||||
if (azureAcc && azureAcc.name) {
|
||||
accountNameMap[accountId] = azureAcc.name
|
||||
return
|
||||
}
|
||||
|
||||
const openaiResponsesAcc = await redis.getOpenaiResponsesAccount(accountId)
|
||||
if (openaiResponsesAcc && openaiResponsesAcc.name) {
|
||||
accountNameMap[accountId] = openaiResponsesAcc.name
|
||||
return
|
||||
}
|
||||
|
||||
const droidAcc = await redis.getDroidAccount(accountId)
|
||||
if (droidAcc && droidAcc.name) {
|
||||
accountNameMap[accountId] = droidAcc.name
|
||||
return
|
||||
}
|
||||
|
||||
const ccrAcc = await redis.getCcrAccount(accountId)
|
||||
if (ccrAcc && ccrAcc.name) {
|
||||
accountNameMap[accountId] = ccrAcc.name
|
||||
return
|
||||
}
|
||||
|
||||
const openaiAcc = await redis.getOpenaiAccount(accountId)
|
||||
if (openaiAcc && openaiAcc.name) {
|
||||
accountNameMap[accountId] = openaiAcc.name
|
||||
return
|
||||
}
|
||||
|
||||
// 降级显示ID
|
||||
accountNameMap[accountId] = accountId
|
||||
} catch (error) {
|
||||
accountNameMap[accountId] = accountId
|
||||
}
|
||||
|
||||
@@ -247,9 +247,11 @@ const handleResponses = async (req, res) => {
|
||||
|
||||
// 从请求体中提取模型和流式标志
|
||||
let requestedModel = req.body?.model || null
|
||||
const isCodexModel =
|
||||
typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex')
|
||||
|
||||
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5
|
||||
if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') {
|
||||
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),并且不是 Codex 系列,则覆盖为 gpt-5
|
||||
if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) {
|
||||
logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`)
|
||||
requestedModel = 'gpt-5'
|
||||
req.body.model = 'gpt-5' // 同时更新请求体中的模型
|
||||
|
||||
@@ -72,20 +72,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 限流时间筛选器 -->
|
||||
<div class="group relative min-w-[140px]">
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-orange-500 to-red-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||
></div>
|
||||
<CustomDropdown
|
||||
v-model="rateLimitFilter"
|
||||
icon="fa-clock"
|
||||
icon-color="text-orange-500"
|
||||
:options="rateLimitOptions"
|
||||
placeholder="限流时间"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="group relative min-w-[200px]">
|
||||
<div
|
||||
@@ -1898,13 +1884,13 @@
|
||||
<!-- 账户统计弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showAccountStatsModal"
|
||||
:style="{ maxWidth: '1200px' }"
|
||||
title="账户统计汇总"
|
||||
width="90%"
|
||||
:style="{ maxWidth: '1200px' }"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-sm" style="min-width: 800px">
|
||||
<table class="w-full border-collapse text-sm" style="min-width: 1000px">
|
||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="border border-gray-300 px-4 py-2 text-left dark:border-gray-600">
|
||||
@@ -1914,19 +1900,25 @@
|
||||
正常
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流≤1h
|
||||
不可调度
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流≤5h
|
||||
限流0-1h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流≤12h
|
||||
限流1-5h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流≤1d
|
||||
限流5-12h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
异常
|
||||
限流12-24h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流>24h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
其他
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 bg-blue-50 px-4 py-2 text-center font-bold dark:border-gray-600 dark:bg-blue-900/30"
|
||||
@@ -1944,19 +1936,31 @@
|
||||
<span class="text-green-600 dark:text-green-400">{{ stat.normal }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit1h }}</span>
|
||||
<span class="text-yellow-600 dark:text-yellow-400">{{ stat.unschedulable }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit5h }}</span>
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit0_1h }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit12h }}</span>
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit1_5h }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit1d }}</span>
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
stat.rateLimit5_12h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-red-600 dark:text-red-400">{{ stat.abnormal }}</span>
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
stat.rateLimit12_24h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
stat.rateLimitOver24h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-red-600 dark:text-red-400">{{ stat.other }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-300 bg-blue-50 px-4 py-2 text-center font-bold dark:border-gray-600 dark:bg-blue-900/30"
|
||||
@@ -1972,30 +1976,38 @@
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimit1h
|
||||
<span class="text-yellow-600 dark:text-yellow-400">{{
|
||||
accountStatsTotal.unschedulable
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimit5h
|
||||
accountStatsTotal.rateLimit0_1h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimit12h
|
||||
accountStatsTotal.rateLimit1_5h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimit1d
|
||||
accountStatsTotal.rateLimit5_12h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-red-600 dark:text-red-400">{{
|
||||
accountStatsTotal.abnormal
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimit12_24h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimitOver24h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-red-600 dark:text-red-400">{{ accountStatsTotal.other }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
{{ accountStatsTotal.total }}
|
||||
</td>
|
||||
@@ -2038,8 +2050,7 @@ const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X
|
||||
const accountGroups = ref([])
|
||||
const groupFilter = ref('all')
|
||||
const platformFilter = ref('all')
|
||||
const statusFilter = ref('normal') // 新增:状态过滤 (normal/abnormal/all)
|
||||
const rateLimitFilter = ref('all') // 新增:限流时间过滤 (all/1h/5h/12h/1d)
|
||||
const statusFilter = ref('normal') // 状态过滤 (normal/rateLimited/other/all)
|
||||
const searchKeyword = ref('')
|
||||
const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize'
|
||||
const getInitialPageSize = () => {
|
||||
@@ -2109,7 +2120,8 @@ const sortOptions = ref([
|
||||
{ value: 'dailyTokens', label: '按今日Token排序', icon: 'fa-coins' },
|
||||
{ value: 'dailyRequests', label: '按今日请求数排序', icon: 'fa-chart-line' },
|
||||
{ value: 'totalTokens', label: '按总Token排序', icon: 'fa-database' },
|
||||
{ value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' }
|
||||
{ value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' },
|
||||
{ value: 'rateLimitTime', label: '按限流时间排序', icon: 'fa-hourglass' }
|
||||
])
|
||||
|
||||
const platformOptions = ref([
|
||||
@@ -2128,18 +2140,12 @@ const platformOptions = ref([
|
||||
|
||||
const statusOptions = ref([
|
||||
{ value: 'normal', label: '正常', icon: 'fa-check-circle' },
|
||||
{ value: 'abnormal', label: '异常', icon: 'fa-exclamation-triangle' },
|
||||
{ value: 'unschedulable', label: '不可调度', icon: 'fa-ban' },
|
||||
{ value: 'rateLimited', label: '限流', icon: 'fa-hourglass-half' },
|
||||
{ value: 'other', label: '其他', icon: 'fa-exclamation-triangle' },
|
||||
{ value: 'all', label: '全部状态', icon: 'fa-list' }
|
||||
])
|
||||
|
||||
const rateLimitOptions = ref([
|
||||
{ value: 'all', label: '全部限流', icon: 'fa-infinity' },
|
||||
{ value: '1h', label: '限流≤1小时', icon: 'fa-hourglass-start' },
|
||||
{ value: '5h', label: '限流≤5小时', icon: 'fa-hourglass-half' },
|
||||
{ value: '12h', label: '限流≤12小时', icon: 'fa-hourglass-end' },
|
||||
{ value: '1d', label: '限流≤1天', icon: 'fa-calendar-day' }
|
||||
])
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
const options = [
|
||||
{ value: 'all', label: '所有账户', icon: 'fa-globe' },
|
||||
@@ -2376,47 +2382,33 @@ const sortedAccounts = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
// 状态过滤 (normal/abnormal/all)
|
||||
// 状态过滤 (normal/unschedulable/rateLimited/other/all)
|
||||
// 限流: isActive && rate-limited (最高优先级)
|
||||
// 正常: isActive && !rate-limited && !blocked && schedulable
|
||||
// 不可调度: isActive && !rate-limited && !blocked && schedulable === false
|
||||
// 其他: 非限流的(未激活 || 被阻止)
|
||||
if (statusFilter.value !== 'all') {
|
||||
sourceAccounts = sourceAccounts.filter((account) => {
|
||||
const isNormal =
|
||||
account.isActive &&
|
||||
account.status !== 'blocked' &&
|
||||
account.status !== 'unauthorized' &&
|
||||
account.schedulable !== false &&
|
||||
!isAccountRateLimited(account)
|
||||
const isRateLimited = isAccountRateLimited(account)
|
||||
const isBlocked = account.status === 'blocked' || account.status === 'unauthorized'
|
||||
|
||||
if (statusFilter.value === 'normal') {
|
||||
return isNormal
|
||||
} else if (statusFilter.value === 'abnormal') {
|
||||
return !isNormal
|
||||
if (statusFilter.value === 'rateLimited') {
|
||||
// 限流: 激活且限流中(优先判断)
|
||||
return account.isActive && isRateLimited
|
||||
} else if (statusFilter.value === 'normal') {
|
||||
// 正常: 激活且非限流且非阻止且可调度
|
||||
return account.isActive && !isRateLimited && !isBlocked && account.schedulable !== false
|
||||
} else if (statusFilter.value === 'unschedulable') {
|
||||
// 不可调度: 激活且非限流且非阻止但不可调度
|
||||
return account.isActive && !isRateLimited && !isBlocked && account.schedulable === false
|
||||
} else if (statusFilter.value === 'other') {
|
||||
// 其他: 非限流的异常账户(未激活或被阻止)
|
||||
return !isRateLimited && (!account.isActive || isBlocked)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// 限流时间过滤 (all/1h/5h/12h/1d)
|
||||
if (rateLimitFilter.value !== 'all') {
|
||||
sourceAccounts = sourceAccounts.filter((account) => {
|
||||
const rateLimitMinutes = getRateLimitRemainingMinutes(account)
|
||||
if (!rateLimitMinutes || rateLimitMinutes <= 0) return false
|
||||
|
||||
const minutes = Math.floor(rateLimitMinutes)
|
||||
switch (rateLimitFilter.value) {
|
||||
case '1h':
|
||||
return minutes <= 60
|
||||
case '5h':
|
||||
return minutes <= 300
|
||||
case '12h':
|
||||
return minutes <= 720
|
||||
case '1d':
|
||||
return minutes <= 1440
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!accountsSortBy.value) return sourceAccounts
|
||||
|
||||
const sorted = [...sourceAccounts].sort((a, b) => {
|
||||
@@ -2447,6 +2439,23 @@ const sortedAccounts = computed(() => {
|
||||
bVal = b.isActive ? 1 : 0
|
||||
}
|
||||
|
||||
// 处理限流时间排序: 未限流优先,然后按剩余时间从小到大
|
||||
if (accountsSortBy.value === 'rateLimitTime') {
|
||||
const aIsRateLimited = isAccountRateLimited(a)
|
||||
const bIsRateLimited = isAccountRateLimited(b)
|
||||
const aMinutes = aIsRateLimited ? getRateLimitRemainingMinutes(a) : 0
|
||||
const bMinutes = bIsRateLimited ? getRateLimitRemainingMinutes(b) : 0
|
||||
|
||||
// 未限流的排在前面
|
||||
if (!aIsRateLimited && bIsRateLimited) return -1
|
||||
if (aIsRateLimited && !bIsRateLimited) return 1
|
||||
|
||||
// 都未限流或都限流时,按剩余时间升序
|
||||
if (aMinutes < bMinutes) return -1
|
||||
if (aMinutes > bMinutes) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
if (aVal < bVal) return accountsSortOrder.value === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return accountsSortOrder.value === 'asc' ? 1 : -1
|
||||
return 0
|
||||
@@ -2479,51 +2488,66 @@ const accountStats = computed(() => {
|
||||
.map((p) => {
|
||||
const platformAccounts = accounts.value.filter((acc) => acc.platform === p.value)
|
||||
|
||||
const normal = platformAccounts.filter((acc) => {
|
||||
return (
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!isAccountRateLimited(acc)
|
||||
)
|
||||
}).length
|
||||
|
||||
const abnormal = platformAccounts.filter((acc) => {
|
||||
return !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
}).length
|
||||
|
||||
// 先筛选限流账户(优先级最高)
|
||||
const rateLimitedAccounts = platformAccounts.filter((acc) => isAccountRateLimited(acc))
|
||||
|
||||
const rateLimit1h = rateLimitedAccounts.filter((acc) => {
|
||||
// 正常: 非限流 && 激活 && 非阻止 && 可调度
|
||||
const normal = platformAccounts.filter((acc) => {
|
||||
const isRateLimited = isAccountRateLimited(acc)
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
return !isRateLimited && acc.isActive && !isBlocked && acc.schedulable !== false
|
||||
}).length
|
||||
|
||||
// 不可调度: 非限流 && 激活 && 非阻止 && 不可调度
|
||||
const unschedulable = platformAccounts.filter((acc) => {
|
||||
const isRateLimited = isAccountRateLimited(acc)
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
return !isRateLimited && acc.isActive && !isBlocked && acc.schedulable === false
|
||||
}).length
|
||||
|
||||
// 其他: 非限流的异常账户(未激活或被阻止)
|
||||
const other = platformAccounts.filter((acc) => {
|
||||
const isRateLimited = isAccountRateLimited(acc)
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
return !isRateLimited && (!acc.isActive || isBlocked)
|
||||
}).length
|
||||
|
||||
const rateLimit0_1h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 0 && minutes <= 60
|
||||
}).length
|
||||
|
||||
const rateLimit5h = rateLimitedAccounts.filter((acc) => {
|
||||
const rateLimit1_5h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 0 && minutes <= 300
|
||||
return minutes > 60 && minutes <= 300
|
||||
}).length
|
||||
|
||||
const rateLimit12h = rateLimitedAccounts.filter((acc) => {
|
||||
const rateLimit5_12h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 0 && minutes <= 720
|
||||
return minutes > 300 && minutes <= 720
|
||||
}).length
|
||||
|
||||
const rateLimit1d = rateLimitedAccounts.filter((acc) => {
|
||||
const rateLimit12_24h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 0 && minutes <= 1440
|
||||
return minutes > 720 && minutes <= 1440
|
||||
}).length
|
||||
|
||||
const rateLimitOver24h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 1440
|
||||
}).length
|
||||
|
||||
return {
|
||||
platform: p.value,
|
||||
platformLabel: p.label,
|
||||
normal,
|
||||
rateLimit1h,
|
||||
rateLimit5h,
|
||||
rateLimit12h,
|
||||
rateLimit1d,
|
||||
abnormal,
|
||||
unschedulable,
|
||||
rateLimit0_1h,
|
||||
rateLimit1_5h,
|
||||
rateLimit5_12h,
|
||||
rateLimit12_24h,
|
||||
rateLimitOver24h,
|
||||
other,
|
||||
total: platformAccounts.length
|
||||
}
|
||||
})
|
||||
@@ -2535,21 +2559,25 @@ const accountStatsTotal = computed(() => {
|
||||
return accountStats.value.reduce(
|
||||
(total, stat) => {
|
||||
total.normal += stat.normal
|
||||
total.rateLimit1h += stat.rateLimit1h
|
||||
total.rateLimit5h += stat.rateLimit5h
|
||||
total.rateLimit12h += stat.rateLimit12h
|
||||
total.rateLimit1d += stat.rateLimit1d
|
||||
total.abnormal += stat.abnormal
|
||||
total.unschedulable += stat.unschedulable
|
||||
total.rateLimit0_1h += stat.rateLimit0_1h
|
||||
total.rateLimit1_5h += stat.rateLimit1_5h
|
||||
total.rateLimit5_12h += stat.rateLimit5_12h
|
||||
total.rateLimit12_24h += stat.rateLimit12_24h
|
||||
total.rateLimitOver24h += stat.rateLimitOver24h
|
||||
total.other += stat.other
|
||||
total.total += stat.total
|
||||
return total
|
||||
},
|
||||
{
|
||||
normal: 0,
|
||||
rateLimit1h: 0,
|
||||
rateLimit5h: 0,
|
||||
rateLimit12h: 0,
|
||||
rateLimit1d: 0,
|
||||
abnormal: 0,
|
||||
unschedulable: 0,
|
||||
rateLimit0_1h: 0,
|
||||
rateLimit1_5h: 0,
|
||||
rateLimit5_12h: 0,
|
||||
rateLimit12_24h: 0,
|
||||
rateLimitOver24h: 0,
|
||||
other: 0,
|
||||
total: 0
|
||||
}
|
||||
)
|
||||
@@ -3351,8 +3379,21 @@ const isAccountRateLimited = (account) => {
|
||||
const getRateLimitRemainingMinutes = (account) => {
|
||||
if (!account || !account.rateLimitStatus) return 0
|
||||
|
||||
if (typeof account.rateLimitStatus === 'object' && account.rateLimitStatus.remainingMinutes) {
|
||||
return account.rateLimitStatus.remainingMinutes
|
||||
if (typeof account.rateLimitStatus === 'object') {
|
||||
const status = account.rateLimitStatus
|
||||
if (Number.isFinite(status.minutesRemaining)) {
|
||||
return Math.max(0, Math.ceil(status.minutesRemaining))
|
||||
}
|
||||
if (Number.isFinite(status.remainingMinutes)) {
|
||||
return Math.max(0, Math.ceil(status.remainingMinutes))
|
||||
}
|
||||
if (Number.isFinite(status.remainingSeconds)) {
|
||||
return Math.max(0, Math.ceil(status.remainingSeconds / 60))
|
||||
}
|
||||
if (status.rateLimitResetAt) {
|
||||
const diffMs = new Date(status.rateLimitResetAt).getTime() - Date.now()
|
||||
return diffMs > 0 ? Math.ceil(diffMs / 60000) : 0
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有 rateLimitUntil 字段,计算剩余时间
|
||||
|
||||
@@ -676,21 +676,19 @@
|
||||
|
||||
<!-- 最近使用记录 -->
|
||||
<div class="mb-4 sm:mb-6 md:mb-8">
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h4 class="text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg">
|
||||
最近使用记录
|
||||
</h4>
|
||||
<button
|
||||
class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
:disabled="usageRecordsLoading"
|
||||
@click="loadUsageRecords"
|
||||
>
|
||||
<i :class="['fas', usageRecordsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt']"></i>
|
||||
<span class="ml-1">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">最近使用记录</h3>
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-blue-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700 sm:gap-2"
|
||||
:disabled="usageRecordsLoading"
|
||||
@click="loadUsageRecords"
|
||||
>
|
||||
<i :class="['fas fa-sync-alt text-xs', { 'animate-spin': usageRecordsLoading }]"></i>
|
||||
<span class="hidden sm:inline">{{ usageRecordsLoading ? '刷新中' : '刷新' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div v-if="usageRecordsLoading" class="py-12 text-center">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-500 dark:text-gray-400">正在加载使用记录...</p>
|
||||
@@ -701,110 +699,94 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full min-w-[800px] text-sm">
|
||||
<table class="w-full" style="min-width: 1100px">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th
|
||||
class="border-b border-gray-200 px-3 py-2 text-left font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
class="min-w-[140px] border-b border-gray-200 px-3 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
时间
|
||||
</th>
|
||||
<th
|
||||
class="border-b border-gray-200 px-3 py-2 text-left font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
class="min-w-[140px] border-b border-gray-200 px-3 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
API Key
|
||||
</th>
|
||||
<th
|
||||
class="border-b border-gray-200 px-3 py-2 text-left font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
class="min-w-[140px] border-b border-gray-200 px-3 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
账户
|
||||
</th>
|
||||
<th
|
||||
class="border-b border-gray-200 px-3 py-2 text-left font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
class="min-w-[180px] border-b border-gray-200 px-3 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
模型
|
||||
</th>
|
||||
<th
|
||||
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
class="min-w-[90px] border-b border-gray-200 px-3 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
输入
|
||||
</th>
|
||||
<th
|
||||
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
class="min-w-[90px] border-b border-gray-200 px-3 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
输出
|
||||
</th>
|
||||
<th
|
||||
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
class="min-w-[90px] border-b border-gray-200 px-3 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
缓存创建
|
||||
</th>
|
||||
<th
|
||||
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
class="min-w-[90px] border-b border-gray-200 px-3 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
缓存读取
|
||||
</th>
|
||||
<th
|
||||
class="border-b border-gray-200 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
class="min-w-[100px] border-b border-gray-200 px-3 py-3 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
成本
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
|
||||
<tr
|
||||
v-for="(record, index) in usageRecords"
|
||||
:key="index"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<td
|
||||
class="border-b border-gray-100 px-3 py-2 text-gray-700 dark:border-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<td class="px-3 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatRecordTime(record.timestamp) }}
|
||||
</td>
|
||||
<td
|
||||
class="border-b border-gray-100 px-3 py-2 text-gray-700 dark:border-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="max-w-[120px] truncate" :title="record.apiKeyName">
|
||||
<td class="px-3 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div class="truncate" :title="record.apiKeyName">
|
||||
{{ record.apiKeyName }}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="border-b border-gray-100 px-3 py-2 text-gray-700 dark:border-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="max-w-[120px] truncate" :title="record.accountName">
|
||||
<td class="px-3 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div class="truncate" :title="record.accountName">
|
||||
{{ record.accountName }}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="border-b border-gray-100 px-3 py-2 text-gray-700 dark:border-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="max-w-[150px] truncate" :title="record.model">
|
||||
<td class="px-3 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div class="truncate" :title="record.model">
|
||||
{{ record.model }}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="border-b border-gray-100 px-3 py-2 text-right text-gray-700 dark:border-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<td class="px-3 py-3 text-right text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatNumber(record.inputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="border-b border-gray-100 px-3 py-2 text-right text-gray-700 dark:border-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<td class="px-3 py-3 text-right text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatNumber(record.outputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="border-b border-gray-100 px-3 py-2 text-right text-gray-700 dark:border-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<td class="px-3 py-3 text-right text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatNumber(record.cacheCreateTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="border-b border-gray-100 px-3 py-2 text-right text-gray-700 dark:border-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<td class="px-3 py-3 text-right text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="border-b border-gray-100 px-3 py-2 text-right font-medium text-gray-700 dark:border-gray-700 dark:text-gray-300"
|
||||
class="px-3 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
${{ formatCost(record.cost) }}
|
||||
</td>
|
||||
@@ -813,14 +795,120 @@
|
||||
</table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="usageRecordsTotal > usageRecords.length" class="mt-4 flex justify-center">
|
||||
<button
|
||||
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
:disabled="usageRecordsLoading"
|
||||
@click="loadMoreUsageRecords"
|
||||
>
|
||||
加载更多 (剩余 {{ usageRecordsTotal - usageRecords.length }} 条)
|
||||
</button>
|
||||
<div v-if="usageRecordsTotal > 0" class="mt-4 space-y-3">
|
||||
<!-- 分页信息和每页数量选择 -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
显示 {{ (usageRecordsCurrentPage - 1) * usageRecordsPageSize + 1 }} -
|
||||
{{ Math.min(usageRecordsCurrentPage * usageRecordsPageSize, usageRecordsTotal) }}
|
||||
条,共 {{ usageRecordsTotal }} 条
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-600 dark:text-gray-400">每页显示:</span>
|
||||
<select
|
||||
v-model.number="usageRecordsPageSize"
|
||||
class="rounded-lg border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
@change="handleUsageRecordsPageSizeChange(usageRecordsPageSize)"
|
||||
>
|
||||
<option v-for="size in usageRecordsPageSizeOptions" :key="size" :value="size">
|
||||
{{ size }} 条
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页按钮 -->
|
||||
<div class="flex justify-center gap-2">
|
||||
<!-- 上一页 -->
|
||||
<button
|
||||
class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
:disabled="usageRecordsCurrentPage === 1 || usageRecordsLoading"
|
||||
@click="handleUsageRecordsPageChange(usageRecordsCurrentPage - 1)"
|
||||
>
|
||||
<i class="fas fa-chevron-left" />
|
||||
</button>
|
||||
|
||||
<!-- 页码 -->
|
||||
<template v-if="usageRecordsTotalPages <= 7">
|
||||
<button
|
||||
v-for="page in usageRecordsTotalPages"
|
||||
:key="page"
|
||||
class="min-w-[36px] rounded-lg border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="
|
||||
page === usageRecordsCurrentPage
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
"
|
||||
:disabled="usageRecordsLoading"
|
||||
@click="handleUsageRecordsPageChange(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 第一页 -->
|
||||
<button
|
||||
class="min-w-[36px] rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
1 === usageRecordsCurrentPage
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
"
|
||||
@click="handleUsageRecordsPageChange(1)"
|
||||
>
|
||||
1
|
||||
</button>
|
||||
<!-- 省略号 -->
|
||||
<span v-if="usageRecordsCurrentPage > 3" class="px-2 text-gray-500">...</span>
|
||||
<!-- 当前页附近的页码 -->
|
||||
<button
|
||||
v-for="page in [
|
||||
usageRecordsCurrentPage - 1,
|
||||
usageRecordsCurrentPage,
|
||||
usageRecordsCurrentPage + 1
|
||||
].filter((p) => p > 1 && p < usageRecordsTotalPages)"
|
||||
:key="page"
|
||||
class="min-w-[36px] rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
page === usageRecordsCurrentPage
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
"
|
||||
@click="handleUsageRecordsPageChange(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<!-- 省略号 -->
|
||||
<span
|
||||
v-if="usageRecordsCurrentPage < usageRecordsTotalPages - 2"
|
||||
class="px-2 text-gray-500"
|
||||
>...</span
|
||||
>
|
||||
<!-- 最后一页 -->
|
||||
<button
|
||||
class="min-w-[36px] rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
usageRecordsTotalPages === usageRecordsCurrentPage
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
"
|
||||
@click="handleUsageRecordsPageChange(usageRecordsTotalPages)"
|
||||
>
|
||||
{{ usageRecordsTotalPages }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 下一页 -->
|
||||
<button
|
||||
class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
:disabled="
|
||||
usageRecordsCurrentPage === usageRecordsTotalPages || usageRecordsLoading
|
||||
"
|
||||
@click="handleUsageRecordsPageChange(usageRecordsCurrentPage + 1)"
|
||||
>
|
||||
<i class="fas fa-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -845,8 +933,9 @@ const { isDarkMode } = storeToRefs(themeStore)
|
||||
const usageRecords = ref([])
|
||||
const usageRecordsLoading = ref(false)
|
||||
const usageRecordsTotal = ref(0)
|
||||
const usageRecordsOffset = ref(0)
|
||||
const usageRecordsLimit = ref(50)
|
||||
const usageRecordsCurrentPage = ref(1)
|
||||
const usageRecordsPageSize = ref(20)
|
||||
const usageRecordsPageSizeOptions = [10, 20, 50, 100]
|
||||
|
||||
const {
|
||||
dashboardData,
|
||||
@@ -1639,29 +1728,22 @@ watch(accountUsageTrendData, () => {
|
||||
})
|
||||
|
||||
// 加载使用记录
|
||||
async function loadUsageRecords(reset = true) {
|
||||
async function loadUsageRecords() {
|
||||
if (usageRecordsLoading.value) return
|
||||
|
||||
try {
|
||||
usageRecordsLoading.value = true
|
||||
if (reset) {
|
||||
usageRecordsOffset.value = 0
|
||||
usageRecords.value = []
|
||||
}
|
||||
const offset = (usageRecordsCurrentPage.value - 1) * usageRecordsPageSize.value
|
||||
|
||||
const response = await apiClient.get('/admin/dashboard/usage-records', {
|
||||
params: {
|
||||
limit: usageRecordsLimit.value,
|
||||
offset: usageRecordsOffset.value
|
||||
limit: usageRecordsPageSize.value,
|
||||
offset: offset
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
if (reset) {
|
||||
usageRecords.value = response.data.records || []
|
||||
} else {
|
||||
usageRecords.value = [...usageRecords.value, ...(response.data.records || [])]
|
||||
}
|
||||
usageRecords.value = response.data.records || []
|
||||
usageRecordsTotal.value = response.data.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1672,12 +1754,24 @@ async function loadUsageRecords(reset = true) {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多使用记录
|
||||
async function loadMoreUsageRecords() {
|
||||
usageRecordsOffset.value += usageRecordsLimit.value
|
||||
await loadUsageRecords(false)
|
||||
// 切换页码
|
||||
function handleUsageRecordsPageChange(page) {
|
||||
usageRecordsCurrentPage.value = page
|
||||
loadUsageRecords()
|
||||
}
|
||||
|
||||
// 切换每页数量
|
||||
function handleUsageRecordsPageSizeChange(size) {
|
||||
usageRecordsPageSize.value = size
|
||||
usageRecordsCurrentPage.value = 1 // 重置到第一页
|
||||
loadUsageRecords()
|
||||
}
|
||||
|
||||
// 计算总页数
|
||||
const usageRecordsTotalPages = computed(() => {
|
||||
return Math.ceil(usageRecordsTotal.value / usageRecordsPageSize.value) || 1
|
||||
})
|
||||
|
||||
// 格式化记录时间
|
||||
function formatRecordTime(timestamp) {
|
||||
if (!timestamp) return '-'
|
||||
@@ -1708,12 +1802,6 @@ function formatRecordTime(timestamp) {
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化数字(添加千分位)
|
||||
function formatNumber(num) {
|
||||
if (!num || num === 0) return '0'
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
// 格式化成本
|
||||
function formatCost(cost) {
|
||||
if (!cost || cost === 0) return '0.000000'
|
||||
|
||||
Reference in New Issue
Block a user