mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 00:27:30 +00:00
The cache token breakdown tooltip in `UsageStatsCards` was hidden with `opacity-0`. An element with zero opacity is invisible but still takes part in layout, so its fixed `w-56` (224px) width kept contributing to the document's scrollable overflow. The tooltip is anchored with `left-1/2 -translate-x-1/2`, and on mobile the stats grid falls back to `grid-cols-2`, which puts the trigger icon close to the right edge of the viewport. Centering a 224px box on that point pushes it roughly 26-49px past the right edge, so `/usage` and `/admin/usage` gained a phantom horizontal scroll with nothing visible to explain it. Switching the hidden state to `hidden` (`display: none`) takes the tooltip out of the flow entirely, while `group-hover:block` / `group-focus:block` keep the existing hover and keyboard-focus behaviour. The 150ms opacity transition is dropped, since `display` cannot be transitioned. Verified over Chrome DevTools Protocol at 360/390/414px: `documentElement.scrollWidth` goes from 424 back to 390 (equal to `clientWidth`) with no other overflowing elements remaining, and the tooltip still renders at 224x86 on focus. A sweep of all 39 application routes confirmed these were the only two affected pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
5.7 KiB
Vue
129 lines
5.7 KiB
Vue
<template>
|
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
<div class="card p-4 flex items-center gap-3">
|
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
|
|
<Icon name="document" size="md" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p>
|
|
<p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p>
|
|
<p class="text-xs text-gray-400">{{ t('usage.inSelectedRange') }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4 flex items-center gap-3">
|
|
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p>
|
|
<p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p>
|
|
<p class="flex flex-wrap items-center gap-x-1 text-xs text-gray-500">
|
|
<span>{{ t('usage.in') }}: {{ formatTokens(stats?.total_input_tokens || 0) }}</span>
|
|
<span>/</span>
|
|
<span>{{ t('usage.out') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}</span>
|
|
<span>/</span>
|
|
<span class="group relative inline-flex cursor-help items-center gap-0.5" tabindex="0">
|
|
<span>{{ cacheLabel() }}: {{ formatTokens(stats?.total_cache_tokens || 0) }}</span>
|
|
<svg
|
|
class="h-3.5 w-3.5 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<span
|
|
class="pointer-events-none absolute left-1/2 top-full z-30 mt-2 hidden w-56 -translate-x-1/2 rounded-lg border border-gray-200 bg-white p-3 text-left text-xs text-gray-700 shadow-lg group-hover:block group-focus:block dark:border-dark-600 dark:bg-dark-800 dark:text-dark-200"
|
|
>
|
|
<span class="mb-2 block font-medium text-gray-900 dark:text-white">
|
|
{{ cacheDetailLabel() }}
|
|
</span>
|
|
<span class="flex items-center justify-between gap-3">
|
|
<span>{{ t('usage.cacheCreationTokensLabel') }}</span>
|
|
<span class="tabular-nums">
|
|
{{ formatTokens(stats?.total_cache_creation_tokens || 0) }}
|
|
</span>
|
|
</span>
|
|
<span class="mt-1 flex items-center justify-between gap-3">
|
|
<span>{{ t('usage.cacheReadTokensLabel') }}</span>
|
|
<span class="tabular-nums">
|
|
{{ formatTokens(stats?.total_cache_read_tokens || 0) }}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4 flex items-center gap-3">
|
|
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
|
|
<Icon name="dollar" size="md" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
|
|
<p class="text-xl font-bold text-green-600">
|
|
${{ (stats?.total_actual_cost || 0).toFixed(4) }}
|
|
</p>
|
|
<p class="text-xs text-gray-400">
|
|
<template v-if="showAccountCost && totalAccountCost != null">
|
|
<span class="text-orange-500">{{ t('usage.accountCost') }} ${{ totalAccountCost.toFixed(4) }}</span>
|
|
<span> · </span>
|
|
</template>
|
|
<span>
|
|
{{ t('usage.standardCost') }}
|
|
<span :class="{ 'line-through': strikeStandardCost }">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4 flex items-center gap-3">
|
|
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
|
|
<Icon name="clock" size="md" />
|
|
</div>
|
|
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.avgDuration') }}</p><p class="text-xl font-bold">{{ formatDuration(stats?.average_duration_ms || 0) }}</p></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import type { AdminUsageStatsResponse } from '@/api/admin/usage'
|
|
import type { UsageStatsResponse } from '@/types'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
stats: (AdminUsageStatsResponse | UsageStatsResponse) | null
|
|
showAccountCost?: boolean
|
|
strikeStandardCost?: boolean
|
|
}>(), {
|
|
showAccountCost: true,
|
|
strikeStandardCost: false,
|
|
})
|
|
|
|
const { t } = useI18n()
|
|
|
|
const totalAccountCost = computed(() => {
|
|
const stats = props.stats as (AdminUsageStatsResponse & { total_account_cost?: number }) | null
|
|
return stats?.total_account_cost ?? null
|
|
})
|
|
const showAccountCost = computed(() => props.showAccountCost)
|
|
const strikeStandardCost = computed(() => props.strikeStandardCost)
|
|
|
|
const formatDuration = (ms: number) =>
|
|
ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s`
|
|
|
|
const formatTokens = (value: number) => {
|
|
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B'
|
|
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M'
|
|
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K'
|
|
return value.toLocaleString()
|
|
}
|
|
|
|
const cacheLabel = () => t('usage.cacheTotal')
|
|
const cacheDetailLabel = () => t('usage.cacheBreakdown')
|
|
</script>
|