|
|
<template> |
|
|
<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 lg:grid-cols-4" |
|
|
> |
|
|
<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"> |
|
|
总API Keys |
|
|
</p> |
|
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl"> |
|
|
{{ dashboardData.totalApiKeys }} |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
活跃: {{ dashboardData.activeApiKeys || 0 }} |
|
|
</p> |
|
|
</div> |
|
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600"> |
|
|
<i class="fas fa-key" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="stat-card"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div class="flex-1"> |
|
|
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> |
|
|
服务账户 |
|
|
</p> |
|
|
<div class="flex flex-wrap items-baseline gap-x-2"> |
|
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl"> |
|
|
{{ dashboardData.totalAccounts }} |
|
|
</p> |
|
|
|
|
|
<div v-if="dashboardData.accountsByPlatform" class="flex items-center gap-2"> |
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
dashboardData.accountsByPlatform.claude && |
|
|
dashboardData.accountsByPlatform.claude.total > 0 |
|
|
" |
|
|
class="inline-flex items-center gap-0.5" |
|
|
:title="`Claude: ${dashboardData.accountsByPlatform.claude.total} 个 (正常: ${dashboardData.accountsByPlatform.claude.normal})`" |
|
|
> |
|
|
<i class="fas fa-brain text-xs text-indigo-600" /> |
|
|
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ |
|
|
dashboardData.accountsByPlatform.claude.total |
|
|
}}</span> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
dashboardData.accountsByPlatform['claude-console'] && |
|
|
dashboardData.accountsByPlatform['claude-console'].total > 0 |
|
|
" |
|
|
class="inline-flex items-center gap-0.5" |
|
|
:title="`Console: ${dashboardData.accountsByPlatform['claude-console'].total} 个 (正常: ${dashboardData.accountsByPlatform['claude-console'].normal})`" |
|
|
> |
|
|
<i class="fas fa-terminal text-xs text-purple-600" /> |
|
|
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ |
|
|
dashboardData.accountsByPlatform['claude-console'].total |
|
|
}}</span> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
dashboardData.accountsByPlatform.gemini && |
|
|
dashboardData.accountsByPlatform.gemini.total > 0 |
|
|
" |
|
|
class="inline-flex items-center gap-0.5" |
|
|
:title="`Gemini: ${dashboardData.accountsByPlatform.gemini.total} 个 (正常: ${dashboardData.accountsByPlatform.gemini.normal})`" |
|
|
> |
|
|
<i class="fas fa-robot text-xs text-yellow-600" /> |
|
|
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ |
|
|
dashboardData.accountsByPlatform.gemini.total |
|
|
}}</span> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
dashboardData.accountsByPlatform.bedrock && |
|
|
dashboardData.accountsByPlatform.bedrock.total > 0 |
|
|
" |
|
|
class="inline-flex items-center gap-0.5" |
|
|
:title="`Bedrock: ${dashboardData.accountsByPlatform.bedrock.total} 个 (正常: ${dashboardData.accountsByPlatform.bedrock.normal})`" |
|
|
> |
|
|
<i class="fab fa-aws text-xs text-orange-600" /> |
|
|
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ |
|
|
dashboardData.accountsByPlatform.bedrock.total |
|
|
}}</span> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
dashboardData.accountsByPlatform.openai && |
|
|
dashboardData.accountsByPlatform.openai.total > 0 |
|
|
" |
|
|
class="inline-flex items-center gap-0.5" |
|
|
:title="`OpenAI: ${dashboardData.accountsByPlatform.openai.total} 个 (正常: ${dashboardData.accountsByPlatform.openai.normal})`" |
|
|
> |
|
|
<i class="fas fa-openai text-xs text-gray-100" /> |
|
|
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ |
|
|
dashboardData.accountsByPlatform.openai.total |
|
|
}}</span> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
dashboardData.accountsByPlatform.azure_openai && |
|
|
dashboardData.accountsByPlatform.azure_openai.total > 0 |
|
|
" |
|
|
class="inline-flex items-center gap-0.5" |
|
|
:title="`Azure OpenAI: ${dashboardData.accountsByPlatform.azure_openai.total} 个 (正常: ${dashboardData.accountsByPlatform.azure_openai.normal})`" |
|
|
> |
|
|
<i class="fab fa-microsoft text-xs text-blue-600" /> |
|
|
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ |
|
|
dashboardData.accountsByPlatform.azure_openai.total |
|
|
}}</span> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
dashboardData.accountsByPlatform['openai-responses'] && |
|
|
dashboardData.accountsByPlatform['openai-responses'].total > 0 |
|
|
" |
|
|
class="inline-flex items-center gap-0.5" |
|
|
:title="`OpenAI Responses: ${dashboardData.accountsByPlatform['openai-responses'].total} 个 (正常: ${dashboardData.accountsByPlatform['openai-responses'].normal})`" |
|
|
> |
|
|
<i class="fas fa-server text-xs text-cyan-600" /> |
|
|
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ |
|
|
dashboardData.accountsByPlatform['openai-responses'].total |
|
|
}}</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
正常: {{ dashboardData.normalAccounts || 0 }} |
|
|
<span v-if="dashboardData.abnormalAccounts > 0" class="text-red-600"> |
|
|
| 异常: {{ dashboardData.abnormalAccounts }} |
|
|
</span> |
|
|
<span |
|
|
v-if="dashboardData.pausedAccounts > 0" |
|
|
class="text-gray-600 dark:text-gray-400" |
|
|
> |
|
|
| 停止调度: {{ dashboardData.pausedAccounts }} |
|
|
</span> |
|
|
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600"> |
|
|
| 限流: {{ dashboardData.rateLimitedAccounts }} |
|
|
</span> |
|
|
</p> |
|
|
</div> |
|
|
<div class="stat-icon ml-2 flex-shrink-0 bg-gradient-to-br from-green-500 to-green-600"> |
|
|
<i class="fas fa-user-circle" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<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"> |
|
|
{{ dashboardData.todayRequests }} |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
总请求: {{ formatNumber(dashboardData.totalRequests || 0) }} |
|
|
</p> |
|
|
</div> |
|
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600"> |
|
|
<i class="fas fa-chart-line" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<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-green-600 sm:text-3xl"> |
|
|
{{ dashboardData.systemStatus }} |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
运行时间: {{ formattedUptime }} |
|
|
</p> |
|
|
</div> |
|
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500"> |
|
|
<i class="fas fa-heartbeat" /> |
|
|
</div> |
|
|
</div> |
|
|
</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 lg:grid-cols-4" |
|
|
> |
|
|
<div class="stat-card"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div class="mr-8 flex-1"> |
|
|
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> |
|
|
今日Token |
|
|
</p> |
|
|
<div class="mb-2 flex flex-wrap items-baseline gap-2"> |
|
|
<p class="text-xl font-bold text-blue-600 sm:text-2xl md:text-3xl"> |
|
|
{{ |
|
|
formatNumber( |
|
|
(dashboardData.todayInputTokens || 0) + |
|
|
(dashboardData.todayOutputTokens || 0) + |
|
|
(dashboardData.todayCacheCreateTokens || 0) + |
|
|
(dashboardData.todayCacheReadTokens || 0) |
|
|
) |
|
|
}} |
|
|
</p> |
|
|
<span class="text-sm font-medium text-green-600" |
|
|
>/ {{ costsData.todayCosts.formatted.totalCost }}</span |
|
|
> |
|
|
</div> |
|
|
<div class="text-xs text-gray-500 dark:text-gray-400"> |
|
|
<div class="flex flex-wrap items-center justify-between gap-x-4"> |
|
|
<span |
|
|
>输入: |
|
|
<span class="font-medium">{{ |
|
|
formatNumber(dashboardData.todayInputTokens || 0) |
|
|
}}</span></span |
|
|
> |
|
|
<span |
|
|
>输出: |
|
|
<span class="font-medium">{{ |
|
|
formatNumber(dashboardData.todayOutputTokens || 0) |
|
|
}}</span></span |
|
|
> |
|
|
<span v-if="(dashboardData.todayCacheCreateTokens || 0) > 0" class="text-purple-600" |
|
|
>缓存创建: |
|
|
<span class="font-medium">{{ |
|
|
formatNumber(dashboardData.todayCacheCreateTokens || 0) |
|
|
}}</span></span |
|
|
> |
|
|
<span v-if="(dashboardData.todayCacheReadTokens || 0) > 0" class="text-purple-600" |
|
|
>缓存读取: |
|
|
<span class="font-medium">{{ |
|
|
formatNumber(dashboardData.todayCacheReadTokens || 0) |
|
|
}}</span></span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-indigo-500 to-indigo-600"> |
|
|
<i class="fas fa-coins" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="stat-card"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div class="mr-8 flex-1"> |
|
|
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm"> |
|
|
总Token消耗 |
|
|
</p> |
|
|
<div class="mb-2 flex flex-wrap items-baseline gap-2"> |
|
|
<p class="text-xl font-bold text-emerald-600 sm:text-2xl md:text-3xl"> |
|
|
{{ |
|
|
formatNumber( |
|
|
(dashboardData.totalInputTokens || 0) + |
|
|
(dashboardData.totalOutputTokens || 0) + |
|
|
(dashboardData.totalCacheCreateTokens || 0) + |
|
|
(dashboardData.totalCacheReadTokens || 0) |
|
|
) |
|
|
}} |
|
|
</p> |
|
|
<span class="text-sm font-medium text-green-600" |
|
|
>/ {{ costsData.totalCosts.formatted.totalCost }}</span |
|
|
> |
|
|
</div> |
|
|
<div class="text-xs text-gray-500 dark:text-gray-400"> |
|
|
<div class="flex flex-wrap items-center justify-between gap-x-4"> |
|
|
<span |
|
|
>输入: |
|
|
<span class="font-medium">{{ |
|
|
formatNumber(dashboardData.totalInputTokens || 0) |
|
|
}}</span></span |
|
|
> |
|
|
<span |
|
|
>输出: |
|
|
<span class="font-medium">{{ |
|
|
formatNumber(dashboardData.totalOutputTokens || 0) |
|
|
}}</span></span |
|
|
> |
|
|
<span v-if="(dashboardData.totalCacheCreateTokens || 0) > 0" class="text-purple-600" |
|
|
>缓存创建: |
|
|
<span class="font-medium">{{ |
|
|
formatNumber(dashboardData.totalCacheCreateTokens || 0) |
|
|
}}</span></span |
|
|
> |
|
|
<span v-if="(dashboardData.totalCacheReadTokens || 0) > 0" class="text-purple-600" |
|
|
>缓存读取: |
|
|
<span class="font-medium">{{ |
|
|
formatNumber(dashboardData.totalCacheReadTokens || 0) |
|
|
}}</span></span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-emerald-600"> |
|
|
<i class="fas fa-database" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<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"> |
|
|
实时RPM |
|
|
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span> |
|
|
</p> |
|
|
<p class="text-2xl font-bold text-orange-600 sm:text-3xl"> |
|
|
{{ dashboardData.realtimeRPM || 0 }} |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
每分钟请求数 |
|
|
<span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600"> |
|
|
<i class="fas fa-exclamation-circle" /> 历史数据 |
|
|
</span> |
|
|
</p> |
|
|
</div> |
|
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-orange-500 to-orange-600"> |
|
|
<i class="fas fa-tachometer-alt" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<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"> |
|
|
实时TPM |
|
|
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span> |
|
|
</p> |
|
|
<p class="text-2xl font-bold text-rose-600 sm:text-3xl"> |
|
|
{{ formatNumber(dashboardData.realtimeTPM || 0) }} |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
每分钟Token数 |
|
|
<span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600"> |
|
|
<i class="fas fa-exclamation-circle" /> 历史数据 |
|
|
</span> |
|
|
</p> |
|
|
</div> |
|
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-rose-500 to-rose-600"> |
|
|
<i class="fas fa-rocket" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-8"> |
|
|
<div class="mb-4 flex flex-col gap-4 sm:mb-6"> |
|
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl"> |
|
|
模型使用分布与Token使用趋势 |
|
|
</h3> |
|
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-end"> |
|
|
|
|
|
<div |
|
|
class="flex flex-shrink-0 gap-1 overflow-x-auto rounded-lg bg-gray-100 p-1 dark:bg-gray-700" |
|
|
> |
|
|
<button |
|
|
v-for="option in dateFilter.presetOptions" |
|
|
:key="option.value" |
|
|
:class="[ |
|
|
'rounded-md px-3 py-1 text-sm font-medium transition-colors', |
|
|
dateFilter.preset === option.value && dateFilter.type === 'preset' |
|
|
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800' |
|
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100' |
|
|
]" |
|
|
@click="setDateFilterPreset(option.value)" |
|
|
> |
|
|
{{ option.label }} |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700"> |
|
|
<button |
|
|
:class="[ |
|
|
'rounded-md px-3 py-1 text-sm font-medium transition-colors', |
|
|
trendGranularity === 'day' |
|
|
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800' |
|
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100' |
|
|
]" |
|
|
@click="setTrendGranularity('day')" |
|
|
> |
|
|
<i class="fas fa-calendar-day mr-1" />按天 |
|
|
</button> |
|
|
<button |
|
|
:class="[ |
|
|
'rounded-md px-3 py-1 text-sm font-medium transition-colors', |
|
|
trendGranularity === 'hour' |
|
|
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800' |
|
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100' |
|
|
]" |
|
|
@click="setTrendGranularity('hour')" |
|
|
> |
|
|
<i class="fas fa-clock mr-1" />按小时 |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="flex items-center gap-2"> |
|
|
<el-date-picker |
|
|
v-model="dateFilter.customRange" |
|
|
class="custom-date-picker w-full lg:w-auto" |
|
|
:default-time="defaultTime" |
|
|
:disabled-date="disabledDate" |
|
|
end-placeholder="结束日期" |
|
|
format="YYYY-MM-DD HH:mm:ss" |
|
|
range-separator="至" |
|
|
size="default" |
|
|
start-placeholder="开始日期" |
|
|
style="max-width: 400px" |
|
|
type="datetimerange" |
|
|
value-format="YYYY-MM-DD HH:mm:ss" |
|
|
@change="onCustomDateRangeChange" |
|
|
/> |
|
|
<span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600"> |
|
|
<i class="fas fa-info-circle" /> 最多24小时 |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="flex items-center gap-2"> |
|
|
|
|
|
<div class="flex items-center rounded-lg bg-gray-100 px-3 py-1 dark:bg-gray-700"> |
|
|
<label class="relative inline-flex cursor-pointer items-center"> |
|
|
<input v-model="autoRefreshEnabled" class="peer sr-only" type="checkbox" /> |
|
|
|
|
|
<div |
|
|
class="peer relative h-5 w-9 rounded-full bg-gray-300 transition-all duration-200 after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow-sm after:transition-transform after:duration-200 after:content-[''] peer-checked:bg-blue-500 peer-checked:after:translate-x-4 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:bg-gray-600 dark:after:bg-gray-300 dark:peer-focus:ring-blue-600" |
|
|
/> |
|
|
<span |
|
|
class="ml-2.5 flex select-none items-center gap-1 text-sm font-medium text-gray-600 dark:text-gray-300" |
|
|
> |
|
|
<i class="fas fa-redo-alt text-xs text-gray-500 dark:text-gray-400" /> |
|
|
<span>自动刷新</span> |
|
|
<span |
|
|
v-if="autoRefreshEnabled" |
|
|
class="ml-1 font-mono text-xs text-blue-600 transition-opacity" |
|
|
:class="refreshCountdown > 0 ? 'opacity-100' : 'opacity-0'" |
|
|
> |
|
|
{{ refreshCountdown }}s |
|
|
</span> |
|
|
</span> |
|
|
</label> |
|
|
</div> |
|
|
|
|
|
|
|
|
<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="isRefreshing" |
|
|
title="立即刷新数据" |
|
|
@click="refreshAllData()" |
|
|
> |
|
|
<i :class="['fas fa-sync-alt text-xs', { 'animate-spin': isRefreshing }]" /> |
|
|
<span class="hidden sm:inline">{{ isRefreshing ? '刷新中' : '刷新' }}</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> |
|
|
|
|
|
<div class="card p-4 sm:p-6"> |
|
|
<h4 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg"> |
|
|
Token使用分布 |
|
|
</h4> |
|
|
<div class="relative" style="height: 250px"> |
|
|
<canvas ref="modelUsageChart" /> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="card p-4 sm:p-6"> |
|
|
<h4 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg"> |
|
|
详细统计数据 |
|
|
</h4> |
|
|
<div v-if="dashboardModelStats.length === 0" class="py-8 text-center"> |
|
|
<p class="text-sm text-gray-500 sm:text-base">暂无模型使用数据</p> |
|
|
</div> |
|
|
<div v-else class="max-h-[250px] overflow-auto sm:max-h-[300px]"> |
|
|
<table class="min-w-full"> |
|
|
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-700"> |
|
|
<tr> |
|
|
<th |
|
|
class="px-2 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4" |
|
|
> |
|
|
模型 |
|
|
</th> |
|
|
<th |
|
|
class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:table-cell sm:px-4" |
|
|
> |
|
|
请求数 |
|
|
</th> |
|
|
<th |
|
|
class="px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4" |
|
|
> |
|
|
总Token |
|
|
</th> |
|
|
<th |
|
|
class="px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4" |
|
|
> |
|
|
费用 |
|
|
</th> |
|
|
<th |
|
|
class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:table-cell sm:px-4" |
|
|
> |
|
|
占比 |
|
|
</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-600"> |
|
|
<tr |
|
|
v-for="stat in dashboardModelStats" |
|
|
:key="stat.model" |
|
|
class="hover:bg-gray-50 dark:hover:bg-gray-700" |
|
|
> |
|
|
<td class="px-2 py-2 text-xs text-gray-900 dark:text-gray-100 sm:px-4 sm:text-sm"> |
|
|
<span class="block max-w-[100px] truncate sm:max-w-none" :title="stat.model"> |
|
|
{{ stat.model }} |
|
|
</span> |
|
|
</td> |
|
|
<td |
|
|
class="hidden px-2 py-2 text-right text-xs text-gray-600 dark:text-gray-400 sm:table-cell sm:px-4 sm:text-sm" |
|
|
> |
|
|
{{ formatNumber(stat.requests) }} |
|
|
</td> |
|
|
<td |
|
|
class="px-2 py-2 text-right text-xs text-gray-600 dark:text-gray-400 sm:px-4 sm:text-sm" |
|
|
> |
|
|
{{ formatNumber(stat.allTokens) }} |
|
|
</td> |
|
|
<td |
|
|
class="px-2 py-2 text-right text-xs font-medium text-green-600 sm:px-4 sm:text-sm" |
|
|
> |
|
|
{{ stat.formatted ? stat.formatted.total : '$0.000000' }} |
|
|
</td> |
|
|
<td |
|
|
class="hidden px-2 py-2 text-right text-xs font-medium sm:table-cell sm:px-4 sm:text-sm" |
|
|
> |
|
|
<span |
|
|
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300" |
|
|
> |
|
|
{{ calculatePercentage(stat.allTokens, dashboardModelStats) }}% |
|
|
</span> |
|
|
</td> |
|
|
</tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-4 sm:mb-6 md:mb-8"> |
|
|
<div class="card p-4 sm:p-6"> |
|
|
<div class="sm:h-[300px]" style="height: 250px"> |
|
|
<canvas ref="usageTrendChart" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-4 sm:mb-6 md:mb-8"> |
|
|
<div class="card p-4 sm:p-6"> |
|
|
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
|
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 sm:text-lg"> |
|
|
API Keys 使用趋势 |
|
|
</h3> |
|
|
|
|
|
<div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700"> |
|
|
<button |
|
|
:class="[ |
|
|
'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm', |
|
|
apiKeysTrendMetric === 'requests' |
|
|
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800' |
|
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100' |
|
|
]" |
|
|
@click="((apiKeysTrendMetric = 'requests'), updateApiKeysUsageTrendChart())" |
|
|
> |
|
|
<i class="fas fa-exchange-alt mr-1" /><span class="hidden sm:inline">请求次数</span |
|
|
><span class="sm:hidden">请求</span> |
|
|
</button> |
|
|
<button |
|
|
:class="[ |
|
|
'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm', |
|
|
apiKeysTrendMetric === 'tokens' |
|
|
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800' |
|
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100' |
|
|
]" |
|
|
@click="((apiKeysTrendMetric = 'tokens'), updateApiKeysUsageTrendChart())" |
|
|
> |
|
|
<i class="fas fa-coins mr-1" /><span class="hidden sm:inline">Token 数量</span |
|
|
><span class="sm:hidden">Token</span> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
<div class="mb-4 text-xs text-gray-600 dark:text-gray-400 sm:text-sm"> |
|
|
<span v-if="apiKeysTrendData.totalApiKeys > 10"> |
|
|
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key,显示使用量前 10 个 |
|
|
</span> |
|
|
<span v-else> 共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key </span> |
|
|
</div> |
|
|
<div class="sm:h-[350px]" style="height: 300px"> |
|
|
<canvas ref="apiKeysUsageTrendChart" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-4 sm:mb-6 md:mb-8"> |
|
|
<div class="card p-4 sm:p-6"> |
|
|
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
|
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3"> |
|
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 sm:text-lg"> |
|
|
账号使用趋势 |
|
|
</h3> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 sm:text-sm"> |
|
|
当前分组:{{ accountUsageTrendData.groupLabel || '未选择' }} |
|
|
</span> |
|
|
</div> |
|
|
<div class="flex flex-wrap items-center gap-2"> |
|
|
<div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700"> |
|
|
<button |
|
|
v-for="option in accountGroupOptions" |
|
|
:key="option.value" |
|
|
:class="[ |
|
|
'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm', |
|
|
accountUsageGroup === option.value |
|
|
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800' |
|
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100' |
|
|
]" |
|
|
@click="handleAccountUsageGroupChange(option.value)" |
|
|
> |
|
|
{{ option.label }} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
class="mb-4 flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400 sm:text-sm" |
|
|
> |
|
|
<span>共 {{ accountUsageTrendData.totalAccounts || 0 }} 个账号</span> |
|
|
<span |
|
|
v-if="accountUsageTrendData.topAccounts && accountUsageTrendData.topAccounts.length" |
|
|
> |
|
|
显示消耗排名前 {{ accountUsageTrendData.topAccounts.length }} 个账号 |
|
|
</span> |
|
|
</div> |
|
|
<div |
|
|
v-if="!accountUsageTrendData.data || accountUsageTrendData.data.length === 0" |
|
|
class="py-12 text-center text-sm text-gray-500 dark:text-gray-400" |
|
|
> |
|
|
暂无账号使用数据 |
|
|
</div> |
|
|
<div v-else class="sm:h-[350px]" style="height: 300px"> |
|
|
<canvas ref="accountUsageTrendChart" /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</template> |
|
|
|
|
|
<script setup> |
|
|
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue' |
|
|
import { storeToRefs } from 'pinia' |
|
|
import { useDashboardStore } from '@/stores/dashboard' |
|
|
import { useThemeStore } from '@/stores/theme' |
|
|
import Chart from 'chart.js/auto' |
|
|
|
|
|
const dashboardStore = useDashboardStore() |
|
|
const themeStore = useThemeStore() |
|
|
const { isDarkMode } = storeToRefs(themeStore) |
|
|
|
|
|
const { |
|
|
dashboardData, |
|
|
costsData, |
|
|
dashboardModelStats, |
|
|
trendData, |
|
|
apiKeysTrendData, |
|
|
accountUsageTrendData, |
|
|
accountUsageGroup, |
|
|
formattedUptime, |
|
|
dateFilter, |
|
|
trendGranularity, |
|
|
apiKeysTrendMetric, |
|
|
defaultTime |
|
|
} = storeToRefs(dashboardStore) |
|
|
|
|
|
const { |
|
|
loadDashboardData, |
|
|
loadApiKeysTrend, |
|
|
setDateFilterPreset, |
|
|
onCustomDateRangeChange, |
|
|
setTrendGranularity, |
|
|
refreshChartsData, |
|
|
setAccountUsageGroup, |
|
|
disabledDate |
|
|
} = dashboardStore |
|
|
|
|
|
|
|
|
const modelUsageChart = ref(null) |
|
|
const usageTrendChart = ref(null) |
|
|
const apiKeysUsageTrendChart = ref(null) |
|
|
const accountUsageTrendChart = ref(null) |
|
|
let modelUsageChartInstance = null |
|
|
let usageTrendChartInstance = null |
|
|
let apiKeysUsageTrendChartInstance = null |
|
|
let accountUsageTrendChartInstance = null |
|
|
|
|
|
const accountGroupOptions = [ |
|
|
{ value: 'claude', label: 'Claude' }, |
|
|
{ value: 'openai', label: 'OpenAI' }, |
|
|
{ value: 'gemini', label: 'Gemini' }, |
|
|
{ value: 'droid', label: 'Droid' } |
|
|
] |
|
|
|
|
|
const accountTrendUpdating = ref(false) |
|
|
|
|
|
|
|
|
const autoRefreshEnabled = ref(false) |
|
|
const autoRefreshInterval = ref(30) |
|
|
const autoRefreshTimer = ref(null) |
|
|
const refreshCountdown = ref(0) |
|
|
const countdownTimer = ref(null) |
|
|
const isRefreshing = ref(false) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const chartColors = computed(() => ({ |
|
|
text: isDarkMode.value ? '#e5e7eb' : '#374151', |
|
|
grid: isDarkMode.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(0, 0, 0, 0.1)', |
|
|
legend: isDarkMode.value ? '#e5e7eb' : '#374151' |
|
|
})) |
|
|
|
|
|
|
|
|
function formatNumber(num) { |
|
|
if (num >= 1000000) { |
|
|
return (num / 1000000).toFixed(2) + 'M' |
|
|
} else if (num >= 1000) { |
|
|
return (num / 1000).toFixed(2) + 'K' |
|
|
} |
|
|
return num.toString() |
|
|
} |
|
|
|
|
|
function formatCostValue(cost) { |
|
|
if (!Number.isFinite(cost)) { |
|
|
return '$0.000000' |
|
|
} |
|
|
if (cost >= 1) { |
|
|
return `$${cost.toFixed(2)}` |
|
|
} |
|
|
if (cost >= 0.01) { |
|
|
return `$${cost.toFixed(3)}` |
|
|
} |
|
|
return `$${cost.toFixed(6)}` |
|
|
} |
|
|
|
|
|
|
|
|
function calculatePercentage(value, stats) { |
|
|
if (!stats || stats.length === 0) return 0 |
|
|
const total = stats.reduce((sum, stat) => sum + stat.allTokens, 0) |
|
|
if (total === 0) return 0 |
|
|
return ((value / total) * 100).toFixed(1) |
|
|
} |
|
|
|
|
|
|
|
|
function createModelUsageChart() { |
|
|
if (!modelUsageChart.value) return |
|
|
|
|
|
if (modelUsageChartInstance) { |
|
|
modelUsageChartInstance.destroy() |
|
|
} |
|
|
|
|
|
const data = dashboardModelStats.value || [] |
|
|
const chartData = { |
|
|
labels: data.map((d) => d.model), |
|
|
datasets: [ |
|
|
{ |
|
|
data: data.map((d) => d.allTokens), |
|
|
backgroundColor: [ |
|
|
'#3B82F6', |
|
|
'#10B981', |
|
|
'#F59E0B', |
|
|
'#EF4444', |
|
|
'#8B5CF6', |
|
|
'#EC4899', |
|
|
'#14B8A6', |
|
|
'#F97316', |
|
|
'#6366F1', |
|
|
'#84CC16' |
|
|
], |
|
|
borderWidth: 0 |
|
|
} |
|
|
] |
|
|
} |
|
|
|
|
|
modelUsageChartInstance = new Chart(modelUsageChart.value, { |
|
|
type: 'doughnut', |
|
|
data: chartData, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
plugins: { |
|
|
legend: { |
|
|
position: 'bottom', |
|
|
labels: { |
|
|
padding: 15, |
|
|
usePointStyle: true, |
|
|
font: { |
|
|
size: 12 |
|
|
}, |
|
|
color: chartColors.value.legend |
|
|
} |
|
|
}, |
|
|
tooltip: { |
|
|
callbacks: { |
|
|
label: function (context) { |
|
|
const label = context.label || '' |
|
|
const value = formatNumber(context.parsed) |
|
|
const percentage = calculatePercentage(context.parsed, data) |
|
|
return `${label}: ${value} (${percentage}%)` |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
function createUsageTrendChart() { |
|
|
if (!usageTrendChart.value) return |
|
|
|
|
|
if (usageTrendChartInstance) { |
|
|
usageTrendChartInstance.destroy() |
|
|
} |
|
|
|
|
|
const data = trendData.value || [] |
|
|
|
|
|
|
|
|
const inputData = data.map((d) => d.inputTokens || 0) |
|
|
const outputData = data.map((d) => d.outputTokens || 0) |
|
|
const cacheCreateData = data.map((d) => d.cacheCreateTokens || 0) |
|
|
const cacheReadData = data.map((d) => d.cacheReadTokens || 0) |
|
|
const requestsData = data.map((d) => d.requests || 0) |
|
|
const costData = data.map((d) => d.cost || 0) |
|
|
|
|
|
|
|
|
const labelField = data[0]?.date ? 'date' : 'hour' |
|
|
const labels = data.map((d) => { |
|
|
|
|
|
if (d.label) { |
|
|
return d.label |
|
|
} |
|
|
|
|
|
if (labelField === 'hour') { |
|
|
|
|
|
const date = new Date(d.hour) |
|
|
const month = String(date.getMonth() + 1).padStart(2, '0') |
|
|
const day = String(date.getDate()).padStart(2, '0') |
|
|
const hour = String(date.getHours()).padStart(2, '0') |
|
|
return `${month}/${day} ${hour}:00` |
|
|
} |
|
|
|
|
|
const dateStr = d.date |
|
|
if (dateStr && dateStr.includes('-')) { |
|
|
const parts = dateStr.split('-') |
|
|
if (parts.length >= 3) { |
|
|
return `${parts[1]}/${parts[2]}` |
|
|
} |
|
|
} |
|
|
return d.date |
|
|
}) |
|
|
|
|
|
const chartData = { |
|
|
labels: labels, |
|
|
datasets: [ |
|
|
{ |
|
|
label: '输入Token', |
|
|
data: inputData, |
|
|
borderColor: 'rgb(102, 126, 234)', |
|
|
backgroundColor: 'rgba(102, 126, 234, 0.1)', |
|
|
tension: 0.3 |
|
|
}, |
|
|
{ |
|
|
label: '输出Token', |
|
|
data: outputData, |
|
|
borderColor: 'rgb(240, 147, 251)', |
|
|
backgroundColor: 'rgba(240, 147, 251, 0.1)', |
|
|
tension: 0.3 |
|
|
}, |
|
|
{ |
|
|
label: '缓存创建Token', |
|
|
data: cacheCreateData, |
|
|
borderColor: 'rgb(59, 130, 246)', |
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)', |
|
|
tension: 0.3 |
|
|
}, |
|
|
{ |
|
|
label: '缓存读取Token', |
|
|
data: cacheReadData, |
|
|
borderColor: 'rgb(147, 51, 234)', |
|
|
backgroundColor: 'rgba(147, 51, 234, 0.1)', |
|
|
tension: 0.3 |
|
|
}, |
|
|
{ |
|
|
label: '费用 (USD)', |
|
|
data: costData, |
|
|
borderColor: 'rgb(34, 197, 94)', |
|
|
backgroundColor: 'rgba(34, 197, 94, 0.1)', |
|
|
tension: 0.3, |
|
|
yAxisID: 'y2' |
|
|
}, |
|
|
{ |
|
|
label: '请求数', |
|
|
data: requestsData, |
|
|
borderColor: 'rgb(16, 185, 129)', |
|
|
backgroundColor: 'rgba(16, 185, 129, 0.1)', |
|
|
tension: 0.3, |
|
|
yAxisID: 'y1' |
|
|
} |
|
|
] |
|
|
} |
|
|
|
|
|
usageTrendChartInstance = new Chart(usageTrendChart.value, { |
|
|
type: 'line', |
|
|
data: chartData, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
interaction: { |
|
|
mode: 'index', |
|
|
intersect: false |
|
|
}, |
|
|
plugins: { |
|
|
title: { |
|
|
display: true, |
|
|
text: 'Token使用趋势', |
|
|
font: { |
|
|
size: 16, |
|
|
weight: 'bold' |
|
|
}, |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
legend: { |
|
|
position: 'top', |
|
|
labels: { |
|
|
color: chartColors.value.legend |
|
|
} |
|
|
}, |
|
|
tooltip: { |
|
|
mode: 'index', |
|
|
intersect: false, |
|
|
itemSort: function (a, b) { |
|
|
|
|
|
const aLabel = a.dataset.label || '' |
|
|
const bLabel = b.dataset.label || '' |
|
|
|
|
|
|
|
|
if (aLabel === '费用 (USD)' || bLabel === '费用 (USD)') { |
|
|
return aLabel === '费用 (USD)' ? -1 : 1 |
|
|
} |
|
|
if (aLabel === '请求数' || bLabel === '请求数') { |
|
|
return aLabel === '请求数' ? 1 : -1 |
|
|
} |
|
|
|
|
|
|
|
|
return b.parsed.y - a.parsed.y |
|
|
}, |
|
|
callbacks: { |
|
|
label: function (context) { |
|
|
const label = context.dataset.label || '' |
|
|
let value = context.parsed.y |
|
|
|
|
|
if (label === '费用 (USD)') { |
|
|
|
|
|
if (value < 0.01) { |
|
|
return label + ': $' + value.toFixed(6) |
|
|
} else { |
|
|
return label + ': $' + value.toFixed(4) |
|
|
} |
|
|
} else if (label === '请求数') { |
|
|
return label + ': ' + value.toLocaleString() + ' 次' |
|
|
} else { |
|
|
|
|
|
if (value >= 1000000) { |
|
|
return label + ': ' + (value / 1000000).toFixed(2) + 'M tokens' |
|
|
} else if (value >= 1000) { |
|
|
return label + ': ' + (value / 1000).toFixed(2) + 'K tokens' |
|
|
} else { |
|
|
return label + ': ' + value.toLocaleString() + ' tokens' |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
x: { |
|
|
type: 'category', |
|
|
display: true, |
|
|
title: { |
|
|
display: true, |
|
|
text: trendGranularity === 'hour' ? '时间' : '日期', |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
ticks: { |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
grid: { |
|
|
color: chartColors.value.grid |
|
|
} |
|
|
}, |
|
|
y: { |
|
|
type: 'linear', |
|
|
display: true, |
|
|
position: 'left', |
|
|
title: { |
|
|
display: true, |
|
|
text: 'Token数量', |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
ticks: { |
|
|
callback: function (value) { |
|
|
return formatNumber(value) |
|
|
}, |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
grid: { |
|
|
color: chartColors.value.grid |
|
|
} |
|
|
}, |
|
|
y1: { |
|
|
type: 'linear', |
|
|
display: true, |
|
|
position: 'right', |
|
|
title: { |
|
|
display: true, |
|
|
text: '请求数', |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
grid: { |
|
|
drawOnChartArea: false |
|
|
}, |
|
|
ticks: { |
|
|
callback: function (value) { |
|
|
return value.toLocaleString() |
|
|
}, |
|
|
color: chartColors.value.text |
|
|
} |
|
|
}, |
|
|
y2: { |
|
|
type: 'linear', |
|
|
display: false, |
|
|
position: 'right' |
|
|
} |
|
|
} |
|
|
} |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
function createApiKeysUsageTrendChart() { |
|
|
if (!apiKeysUsageTrendChart.value) return |
|
|
|
|
|
if (apiKeysUsageTrendChartInstance) { |
|
|
apiKeysUsageTrendChartInstance.destroy() |
|
|
} |
|
|
|
|
|
const data = apiKeysTrendData.value.data || [] |
|
|
const metric = apiKeysTrendMetric.value |
|
|
|
|
|
|
|
|
const colors = [ |
|
|
'#3B82F6', |
|
|
'#10B981', |
|
|
'#F59E0B', |
|
|
'#EF4444', |
|
|
'#8B5CF6', |
|
|
'#EC4899', |
|
|
'#14B8A6', |
|
|
'#F97316', |
|
|
'#6366F1', |
|
|
'#84CC16' |
|
|
] |
|
|
|
|
|
|
|
|
const datasets = |
|
|
apiKeysTrendData.value.topApiKeys?.map((apiKeyId, index) => { |
|
|
const data = apiKeysTrendData.value.data.map((item) => { |
|
|
if (!item.apiKeys || !item.apiKeys[apiKeyId]) return 0 |
|
|
return metric === 'tokens' |
|
|
? item.apiKeys[apiKeyId].tokens |
|
|
: item.apiKeys[apiKeyId].requests || 0 |
|
|
}) |
|
|
|
|
|
|
|
|
const apiKeyName = |
|
|
apiKeysTrendData.value.data.find((item) => item.apiKeys && item.apiKeys[apiKeyId])?.apiKeys[ |
|
|
apiKeyId |
|
|
]?.name || `API Key ${apiKeyId}` |
|
|
|
|
|
return { |
|
|
label: apiKeyName, |
|
|
data: data, |
|
|
borderColor: colors[index % colors.length], |
|
|
backgroundColor: colors[index % colors.length] + '20', |
|
|
tension: 0.4, |
|
|
fill: false |
|
|
} |
|
|
}) || [] |
|
|
|
|
|
|
|
|
const labelField = data[0]?.date ? 'date' : 'hour' |
|
|
|
|
|
const chartData = { |
|
|
labels: data.map((d) => { |
|
|
|
|
|
if (d.label) { |
|
|
return d.label |
|
|
} |
|
|
|
|
|
if (labelField === 'hour') { |
|
|
|
|
|
const date = new Date(d.hour) |
|
|
const month = String(date.getMonth() + 1).padStart(2, '0') |
|
|
const day = String(date.getDate()).padStart(2, '0') |
|
|
const hour = String(date.getHours()).padStart(2, '0') |
|
|
return `${month}/${day} ${hour}:00` |
|
|
} |
|
|
|
|
|
const dateStr = d.date |
|
|
if (dateStr && dateStr.includes('-')) { |
|
|
const parts = dateStr.split('-') |
|
|
if (parts.length >= 3) { |
|
|
return `${parts[1]}/${parts[2]}` |
|
|
} |
|
|
} |
|
|
return d.date |
|
|
}), |
|
|
datasets: datasets |
|
|
} |
|
|
|
|
|
apiKeysUsageTrendChartInstance = new Chart(apiKeysUsageTrendChart.value, { |
|
|
type: 'line', |
|
|
data: chartData, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
plugins: { |
|
|
legend: { |
|
|
position: 'bottom', |
|
|
labels: { |
|
|
padding: 20, |
|
|
usePointStyle: true, |
|
|
font: { |
|
|
size: 12 |
|
|
}, |
|
|
color: chartColors.value.legend |
|
|
} |
|
|
}, |
|
|
tooltip: { |
|
|
mode: 'index', |
|
|
intersect: false, |
|
|
itemSort: function (a, b) { |
|
|
|
|
|
return b.parsed.y - a.parsed.y |
|
|
}, |
|
|
callbacks: { |
|
|
label: function (context) { |
|
|
const label = context.dataset.label || '' |
|
|
const value = context.parsed.y |
|
|
const dataIndex = context.dataIndex |
|
|
const dataPoint = apiKeysTrendData.value.data[dataIndex] |
|
|
|
|
|
|
|
|
const allValues = context.chart.data.datasets |
|
|
.map((dataset, idx) => ({ |
|
|
value: dataset.data[dataIndex] || 0, |
|
|
index: idx |
|
|
})) |
|
|
.sort((a, b) => b.value - a.value) |
|
|
|
|
|
|
|
|
const rank = allValues.findIndex((item) => item.index === context.datasetIndex) + 1 |
|
|
|
|
|
|
|
|
let rankIcon = '' |
|
|
if (rank === 1) rankIcon = '🥇 ' |
|
|
else if (rank === 2) rankIcon = '🥈 ' |
|
|
else if (rank === 3) rankIcon = '🥉 ' |
|
|
|
|
|
if (apiKeysTrendMetric.value === 'tokens') { |
|
|
|
|
|
let formattedValue = '' |
|
|
if (value >= 1000000) { |
|
|
formattedValue = (value / 1000000).toFixed(2) + 'M' |
|
|
} else if (value >= 1000) { |
|
|
formattedValue = (value / 1000).toFixed(2) + 'K' |
|
|
} else { |
|
|
formattedValue = value.toLocaleString() |
|
|
} |
|
|
|
|
|
|
|
|
const apiKeyId = apiKeysTrendData.value.topApiKeys[context.datasetIndex] |
|
|
const apiKeyData = dataPoint?.apiKeys?.[apiKeyId] |
|
|
const cost = apiKeyData?.formattedCost || '$0.00' |
|
|
|
|
|
return `${rankIcon}${label}: ${formattedValue} tokens (${cost})` |
|
|
} else { |
|
|
return `${rankIcon}${label}: ${value.toLocaleString()} 次` |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
x: { |
|
|
type: 'category', |
|
|
display: true, |
|
|
title: { |
|
|
display: true, |
|
|
text: trendGranularity === 'hour' ? '时间' : '日期', |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
ticks: { |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
grid: { |
|
|
color: chartColors.value.grid |
|
|
} |
|
|
}, |
|
|
y: { |
|
|
beginAtZero: true, |
|
|
title: { |
|
|
display: true, |
|
|
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数', |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
ticks: { |
|
|
callback: function (value) { |
|
|
return formatNumber(value) |
|
|
}, |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
grid: { |
|
|
color: chartColors.value.grid |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
async function updateApiKeysUsageTrendChart() { |
|
|
await loadApiKeysTrend(apiKeysTrendMetric.value) |
|
|
await nextTick() |
|
|
createApiKeysUsageTrendChart() |
|
|
} |
|
|
|
|
|
function createAccountUsageTrendChart() { |
|
|
if (!accountUsageTrendChart.value) return |
|
|
|
|
|
if (accountUsageTrendChartInstance) { |
|
|
accountUsageTrendChartInstance.destroy() |
|
|
} |
|
|
|
|
|
const trend = accountUsageTrendData.value?.data || [] |
|
|
const topAccounts = accountUsageTrendData.value?.topAccounts || [] |
|
|
|
|
|
const colors = [ |
|
|
'#2563EB', |
|
|
'#059669', |
|
|
'#D97706', |
|
|
'#DC2626', |
|
|
'#7C3AED', |
|
|
'#F472B6', |
|
|
'#0EA5E9', |
|
|
'#F97316', |
|
|
'#6366F1', |
|
|
'#22C55E' |
|
|
] |
|
|
|
|
|
const datasets = topAccounts.map((accountId, index) => { |
|
|
const dataPoints = trend.map((item) => { |
|
|
if (!item.accounts || !item.accounts[accountId]) return 0 |
|
|
return item.accounts[accountId].cost || 0 |
|
|
}) |
|
|
|
|
|
const accountName = |
|
|
trend.find((item) => item.accounts && item.accounts[accountId])?.accounts[accountId]?.name || |
|
|
`账号 ${String(accountId).slice(0, 6)}` |
|
|
|
|
|
return { |
|
|
label: accountName, |
|
|
data: dataPoints, |
|
|
borderColor: colors[index % colors.length], |
|
|
backgroundColor: colors[index % colors.length] + '20', |
|
|
tension: 0.4, |
|
|
fill: false |
|
|
} |
|
|
}) |
|
|
|
|
|
const labelField = trend[0]?.date ? 'date' : 'hour' |
|
|
|
|
|
const chartData = { |
|
|
labels: trend.map((item) => { |
|
|
if (item.label) { |
|
|
return item.label |
|
|
} |
|
|
|
|
|
if (labelField === 'hour') { |
|
|
const date = new Date(item.hour) |
|
|
const month = String(date.getMonth() + 1).padStart(2, '0') |
|
|
const day = String(date.getDate()).padStart(2, '0') |
|
|
const hour = String(date.getHours()).padStart(2, '0') |
|
|
return `${month}/${day} ${hour}:00` |
|
|
} |
|
|
|
|
|
if (item.date && item.date.includes('-')) { |
|
|
const parts = item.date.split('-') |
|
|
if (parts.length >= 3) { |
|
|
return `${parts[1]}/${parts[2]}` |
|
|
} |
|
|
} |
|
|
|
|
|
return item.date |
|
|
}), |
|
|
datasets |
|
|
} |
|
|
|
|
|
const topAccountIds = topAccounts |
|
|
|
|
|
accountUsageTrendChartInstance = new Chart(accountUsageTrendChart.value, { |
|
|
type: 'line', |
|
|
data: chartData, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
interaction: { |
|
|
mode: 'index', |
|
|
intersect: false |
|
|
}, |
|
|
plugins: { |
|
|
legend: { |
|
|
position: 'bottom', |
|
|
labels: { |
|
|
padding: 20, |
|
|
usePointStyle: true, |
|
|
font: { |
|
|
size: 12 |
|
|
}, |
|
|
color: chartColors.value.legend |
|
|
} |
|
|
}, |
|
|
tooltip: { |
|
|
mode: 'index', |
|
|
intersect: false, |
|
|
itemSort: (a, b) => b.parsed.y - a.parsed.y, |
|
|
callbacks: { |
|
|
label: function (context) { |
|
|
const label = context.dataset.label || '' |
|
|
const value = context.parsed.y || 0 |
|
|
const dataIndex = context.dataIndex |
|
|
const datasetIndex = context.datasetIndex |
|
|
const accountId = topAccountIds[datasetIndex] |
|
|
const dataPoint = accountUsageTrendData.value.data[dataIndex] |
|
|
const accountDetail = dataPoint?.accounts?.[accountId] |
|
|
|
|
|
const allValues = context.chart.data.datasets |
|
|
.map((dataset, idx) => ({ |
|
|
value: dataset.data[dataIndex] || 0, |
|
|
index: idx |
|
|
})) |
|
|
.sort((a, b) => b.value - a.value) |
|
|
|
|
|
const rank = allValues.findIndex((item) => item.index === datasetIndex) + 1 |
|
|
let rankIcon = '' |
|
|
if (rank === 1) rankIcon = '🥇 ' |
|
|
else if (rank === 2) rankIcon = '🥈 ' |
|
|
else if (rank === 3) rankIcon = '🥉 ' |
|
|
|
|
|
const formattedCost = accountDetail?.formattedCost || formatCostValue(value) |
|
|
const requests = accountDetail?.requests || 0 |
|
|
|
|
|
return `${rankIcon}${label}: ${formattedCost} / ${requests.toLocaleString()} 次` |
|
|
} |
|
|
} |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
x: { |
|
|
type: 'category', |
|
|
display: true, |
|
|
title: { |
|
|
display: true, |
|
|
text: trendGranularity.value === 'hour' ? '时间' : '日期', |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
ticks: { |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
grid: { |
|
|
color: chartColors.value.grid |
|
|
} |
|
|
}, |
|
|
y: { |
|
|
beginAtZero: true, |
|
|
title: { |
|
|
display: true, |
|
|
text: '消耗金额 (USD)', |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
ticks: { |
|
|
callback: (value) => formatCostValue(Number(value)), |
|
|
color: chartColors.value.text |
|
|
}, |
|
|
grid: { |
|
|
color: chartColors.value.grid |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}) |
|
|
} |
|
|
|
|
|
async function handleAccountUsageGroupChange(group) { |
|
|
if (accountUsageGroup.value === group || accountTrendUpdating.value) { |
|
|
return |
|
|
} |
|
|
accountTrendUpdating.value = true |
|
|
try { |
|
|
await setAccountUsageGroup(group) |
|
|
await nextTick() |
|
|
createAccountUsageTrendChart() |
|
|
} finally { |
|
|
accountTrendUpdating.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
watch(dashboardModelStats, () => { |
|
|
nextTick(() => createModelUsageChart()) |
|
|
}) |
|
|
|
|
|
watch(trendData, () => { |
|
|
nextTick(() => createUsageTrendChart()) |
|
|
}) |
|
|
|
|
|
watch(apiKeysTrendData, () => { |
|
|
nextTick(() => createApiKeysUsageTrendChart()) |
|
|
}) |
|
|
|
|
|
watch(accountUsageTrendData, () => { |
|
|
nextTick(() => createAccountUsageTrendChart()) |
|
|
}) |
|
|
|
|
|
|
|
|
async function refreshAllData() { |
|
|
if (isRefreshing.value) return |
|
|
|
|
|
isRefreshing.value = true |
|
|
try { |
|
|
await Promise.all([loadDashboardData(), refreshChartsData()]) |
|
|
} finally { |
|
|
isRefreshing.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function startAutoRefresh() { |
|
|
if (!autoRefreshEnabled.value) return |
|
|
|
|
|
|
|
|
refreshCountdown.value = autoRefreshInterval.value |
|
|
|
|
|
|
|
|
if (countdownTimer.value) { |
|
|
clearInterval(countdownTimer.value) |
|
|
} |
|
|
if (autoRefreshTimer.value) { |
|
|
clearTimeout(autoRefreshTimer.value) |
|
|
} |
|
|
|
|
|
|
|
|
countdownTimer.value = setInterval(() => { |
|
|
refreshCountdown.value-- |
|
|
if (refreshCountdown.value <= 0) { |
|
|
clearInterval(countdownTimer.value) |
|
|
} |
|
|
}, 1000) |
|
|
|
|
|
|
|
|
autoRefreshTimer.value = setTimeout(async () => { |
|
|
await refreshAllData() |
|
|
|
|
|
if (autoRefreshEnabled.value) { |
|
|
startAutoRefresh() |
|
|
} |
|
|
}, autoRefreshInterval.value * 1000) |
|
|
} |
|
|
|
|
|
|
|
|
function stopAutoRefresh() { |
|
|
if (countdownTimer.value) { |
|
|
clearInterval(countdownTimer.value) |
|
|
countdownTimer.value = null |
|
|
} |
|
|
if (autoRefreshTimer.value) { |
|
|
clearTimeout(autoRefreshTimer.value) |
|
|
autoRefreshTimer.value = null |
|
|
} |
|
|
refreshCountdown.value = 0 |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
watch(autoRefreshEnabled, (newVal) => { |
|
|
if (newVal) { |
|
|
startAutoRefresh() |
|
|
} else { |
|
|
stopAutoRefresh() |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
watch(isDarkMode, () => { |
|
|
nextTick(() => { |
|
|
createModelUsageChart() |
|
|
createUsageTrendChart() |
|
|
createApiKeysUsageTrendChart() |
|
|
createAccountUsageTrendChart() |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
onMounted(async () => { |
|
|
|
|
|
await refreshAllData() |
|
|
|
|
|
|
|
|
await nextTick() |
|
|
createModelUsageChart() |
|
|
createUsageTrendChart() |
|
|
createApiKeysUsageTrendChart() |
|
|
createAccountUsageTrendChart() |
|
|
}) |
|
|
|
|
|
|
|
|
onUnmounted(() => { |
|
|
stopAutoRefresh() |
|
|
|
|
|
if (modelUsageChartInstance) { |
|
|
modelUsageChartInstance.destroy() |
|
|
} |
|
|
if (usageTrendChartInstance) { |
|
|
usageTrendChartInstance.destroy() |
|
|
} |
|
|
if (apiKeysUsageTrendChartInstance) { |
|
|
apiKeysUsageTrendChartInstance.destroy() |
|
|
} |
|
|
if (accountUsageTrendChartInstance) { |
|
|
accountUsageTrendChartInstance.destroy() |
|
|
} |
|
|
}) |
|
|
</script> |
|
|
|
|
|
<style scoped> |
|
|
|
|
|
.custom-date-picker { |
|
|
font-size: 13px; |
|
|
} |
|
|
|
|
|
|
|
|
@keyframes spin { |
|
|
from { |
|
|
transform: rotate(0deg); |
|
|
} |
|
|
|
|
|
to { |
|
|
transform: rotate(360deg); |
|
|
} |
|
|
} |
|
|
|
|
|
.animate-spin { |
|
|
animation: spin 1s linear infinite; |
|
|
} |
|
|
</style> |
|
|
|