fix: 修复仪表盘和API统计页面的多个问题

- 修复仪表盘天粒度下7天/30天快捷选择无数据的问题
- 修复API Keys页面统计按钮链接路由错误(admin -> admin-next)
- 改进统计页面限制展示,使用3个进度条更直观显示使用情况
- 后端API响应增加当前使用量数据(currentWindowRequests/Tokens/DailyCost)
- 修复教程页面window.location.origin为空的兼容性问题
- 无限制时使用无穷符号(∞)展示,提升用户体验

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-08-04 11:58:26 +08:00
parent 4e3c826b6c
commit fce6d8e1ac
5 changed files with 253 additions and 58 deletions

View File

@@ -269,6 +269,28 @@ router.post('/api/user-stats', async (req, res) => {
}
}
// 获取当前使用量
let currentWindowRequests = 0;
let currentWindowTokens = 0;
let currentDailyCost = 0;
try {
// 获取当前时间窗口的请求次数和Token使用量
if (fullKeyData.rateLimitWindow > 0) {
const client = redis.getClientSafe();
const requestCountKey = `rate_limit:requests:${keyId}`;
const tokenCountKey = `rate_limit:tokens:${keyId}`;
currentWindowRequests = parseInt(await client.get(requestCountKey) || '0');
currentWindowTokens = parseInt(await client.get(tokenCountKey) || '0');
}
// 获取当日费用
currentDailyCost = await redis.getDailyCost(keyId) || 0;
} catch (error) {
logger.warn(`Failed to get current usage for key ${keyId}:`, error);
}
// 构建响应数据只返回该API Key自己的信息确保不泄露其他信息
const responseData = {
id: keyId,
@@ -296,13 +318,17 @@ router.post('/api/user-stats', async (req, res) => {
}
},
// 限制信息(显示配置,不显示当前使用量)
// 限制信息(显示配置当前使用量)
limits: {
tokenLimit: fullKeyData.tokenLimit || 0,
concurrencyLimit: fullKeyData.concurrencyLimit || 0,
rateLimitWindow: fullKeyData.rateLimitWindow || 0,
rateLimitRequests: fullKeyData.rateLimitRequests || 0,
dailyCostLimit: fullKeyData.dailyCostLimit || 0
dailyCostLimit: fullKeyData.dailyCostLimit || 0,
// 当前使用量
currentWindowRequests: currentWindowRequests,
currentWindowTokens: currentWindowTokens,
currentDailyCost: currentDailyCost
},
// 绑定的账户信息只显示ID不显示敏感信息

View File

@@ -6,64 +6,131 @@
<i class="fas fa-shield-alt mr-2 md:mr-3 text-red-500 text-sm md:text-base" />
限制配置
</h3>
<div class="space-y-2 md:space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">Token 限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ statsData.limits.tokenLimit > 0 ? formatNumber(statsData.limits.tokenLimit) : '无限制' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">并发限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ statsData.limits.concurrencyLimit > 0 ? statsData.limits.concurrencyLimit : '无限制' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">速率限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
{{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0
? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟`
: '无限制' }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">每日费用限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ statsData.limits.dailyCostLimit > 0 ? '$' + statsData.limits.dailyCostLimit : '无限制' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">模型限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
<div class="space-y-4 md:space-y-5">
<!-- 每日费用限制 -->
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600 text-sm md:text-base font-medium">每日费用限制</span>
<span class="text-xs md:text-sm text-gray-500">
<span v-if="statsData.limits.dailyCostLimit > 0">
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{ statsData.limits.dailyCostLimit.toFixed(2) }}
</span>
<span v-else class="flex items-center gap-1">
${{ statsData.limits.currentDailyCost.toFixed(4) }} / <i class="fas fa-infinity" />
</span>
</span>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有模型
</span>
</span>
</div>
<div v-if="statsData.limits.dailyCostLimit > 0" class="w-full bg-gray-200 rounded-full h-2">
<div
:class="getDailyCostProgressColor()"
class="h-2 rounded-full transition-all duration-300"
:style="{ width: getDailyCostProgress() + '%' }"
/>
</div>
<div v-else class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" style="width: 0%" />
</div>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">客户端限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
<!-- 时间窗口限制 -->
<div v-if="statsData.limits.rateLimitWindow > 0 && (statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)">
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600 text-sm md:text-base font-medium">
时间窗口限制 ({{ statsData.limits.rateLimitWindow }}分钟)
</span>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有客户端
</div>
<!-- 请求次数限制 -->
<div v-if="statsData.limits.rateLimitRequests > 0" class="space-y-1.5 mb-3">
<div class="flex justify-between items-center text-xs md:text-sm">
<span class="text-gray-500">请求次数</span>
<span class="text-gray-700">
{{ formatNumber(statsData.limits.currentWindowRequests) }} / {{ formatNumber(statsData.limits.rateLimitRequests) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowRequestProgressColor()"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getWindowRequestProgress() + '%' }"
/>
</div>
</div>
<!-- Token使用量限制 -->
<div v-if="statsData.limits.tokenLimit > 0" class="space-y-1.5">
<div class="flex justify-between items-center text-xs md:text-sm">
<span class="text-gray-500">Token 使用量</span>
<span class="text-gray-700">
{{ formatNumber(statsData.limits.currentWindowTokens) }} / {{ formatNumber(statsData.limits.tokenLimit) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowTokenProgressColor()"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getWindowTokenProgress() + '%' }"
/>
</div>
</div>
<div class="mt-2 text-xs text-gray-500">
<i class="fas fa-info-circle mr-1" />
请求次数和Token使用量为"或"的关系任一达到限制即触发限流
</div>
</div>
<!-- 其他限制信息 -->
<div class="pt-2 border-t border-gray-100 space-y-2">
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">并发限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span v-if="statsData.limits.concurrencyLimit > 0">
{{ statsData.limits.concurrencyLimit }}
</span>
<span v-else class="flex items-center gap-1">
<i class="fas fa-infinity text-gray-400" />
</span>
</span>
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">模型限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
</span>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有模型
</span>
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">客户端限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
</span>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有客户端
</span>
</span>
</div>
</div>
</div>
</div>
@@ -158,6 +225,51 @@ const formatNumber = (num) => {
return num.toLocaleString()
}
}
// 获取每日费用进度
const getDailyCostProgress = () => {
if (!statsData.value.limits.dailyCostLimit || statsData.value.limits.dailyCostLimit === 0) return 0
const percentage = (statsData.value.limits.currentDailyCost / statsData.value.limits.dailyCostLimit) * 100
return Math.min(percentage, 100)
}
// 获取每日费用进度条颜色
const getDailyCostProgressColor = () => {
const progress = getDailyCostProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 获取窗口请求进度
const getWindowRequestProgress = () => {
if (!statsData.value.limits.rateLimitRequests || statsData.value.limits.rateLimitRequests === 0) return 0
const percentage = (statsData.value.limits.currentWindowRequests / statsData.value.limits.rateLimitRequests) * 100
return Math.min(percentage, 100)
}
// 获取窗口请求进度条颜色
const getWindowRequestProgressColor = () => {
const progress = getWindowRequestProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-blue-500'
}
// 获取窗口Token进度
const getWindowTokenProgress = () => {
if (!statsData.value.limits.tokenLimit || statsData.value.limits.tokenLimit === 0) return 0
const percentage = (statsData.value.limits.currentWindowTokens / statsData.value.limits.tokenLimit) * 100
return Math.min(percentage, 100)
}
// 获取窗口Token进度条颜色
const getWindowTokenProgressColor = () => {
const progress = getWindowTokenProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-purple-500'
}
</script>
<style scoped>

View File

@@ -311,6 +311,28 @@ export const useDashboardStore = defineStore('dashboard', () => {
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
}
} else if (dateFilter.value.type === 'preset' && trendGranularity.value === 'day') {
// 天粒度的预设时间范围需要传递startDate和endDate参数
const now = new Date()
let startDate, endDate
const option = dateFilter.value.presetOptions.find(opt => opt.value === dateFilter.value.preset)
if (option) {
if (dateFilter.value.preset === 'today') {
// 今日从系统时区的今天0点到23:59
startDate = getSystemTimezoneDay(now, true)
endDate = getSystemTimezoneDay(now, false)
} else {
// 7天或30天从N天前的0点到今天的23:59
const daysAgo = new Date()
daysAgo.setDate(daysAgo.getDate() - (option.days - 1))
startDate = getSystemTimezoneDay(daysAgo, true)
endDate = getSystemTimezoneDay(now, false)
}
url += `&startDate=${encodeURIComponent(startDate.toISOString())}`
url += `&endDate=${encodeURIComponent(endDate.toISOString())}`
}
}
const response = await apiClient.get(url)

View File

@@ -1533,7 +1533,7 @@ const deleteApiKey = async (keyId) => {
const copyApiStatsLink = (apiKey) => {
// 构建统计页面的完整URL
const baseUrl = window.location.origin
const statsUrl = `${baseUrl}/admin/api-stats?apiId=${apiKey.id}`
const statsUrl = `${baseUrl}/admin-next/api-stats?apiId=${apiKey.id}`
// 使用传统的textarea方法复制到剪贴板
const textarea = document.createElement('textarea')

View File

@@ -1132,7 +1132,42 @@ const tutorialSystems = [
// 当前基础URL
const currentBaseUrl = computed(() => {
return window.location.origin
// 更健壮的获取 origin 的方法,兼容旧版浏览器和特殊环境
let origin = ''
if (window.location.origin) {
// 现代浏览器直接支持 origin
origin = window.location.origin
} else {
// 旧版浏览器或特殊环境的兼容处理
const protocol = window.location.protocol
const hostname = window.location.hostname
const port = window.location.port
origin = protocol + '//' + hostname
// 只有在非默认端口时才添加端口号
if (port &&
((protocol === 'http:' && port !== '80') ||
(protocol === 'https:' && port !== '443'))) {
origin += ':' + port
}
}
// 如果还是获取不到,使用当前页面的 URL 推导
if (!origin) {
const currentUrl = window.location.href
const pathStart = currentUrl.indexOf('/', 8) // 跳过 http:// 或 https://
if (pathStart !== -1) {
origin = currentUrl.substring(0, pathStart)
} else {
// 最后的降级方案,使用相对路径
console.warn('无法获取完整的 origin将使用相对路径')
return '/api'
}
}
return origin + '/api'
})
</script>