- Account-level cache TTL override: rewrite Anthropic cache_creation token classification (5m↔1h) in streaming/non-streaming responses - New DB field cache_ttl_overridden in usage_log for billing tracking - Migration 055_add_cache_ttl_overridden - Frontend: CacheTTL override toggle in account create/edit modals - Ent schema regenerated for new usage_log fields Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
854 lines
33 KiB
Vue
854 lines
33 KiB
Vue
<template>
|
||
<AppLayout>
|
||
<TablePageLayout>
|
||
<template #actions>
|
||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||
<!-- Total Requests -->
|
||
<div class="card p-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||
<Icon name="document" size="md" class="text-blue-600 dark:text-blue-400" />
|
||
</div>
|
||
<div>
|
||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
{{ t('usage.totalRequests') }}
|
||
</p>
|
||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||
{{ usageStats?.total_requests?.toLocaleString() || '0' }}
|
||
</p>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('usage.inSelectedRange') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Total Tokens -->
|
||
<div class="card p-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
|
||
<Icon name="cube" size="md" class="text-amber-600 dark:text-amber-400" />
|
||
</div>
|
||
<div>
|
||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
{{ t('usage.totalTokens') }}
|
||
</p>
|
||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||
{{ formatTokens(usageStats?.total_tokens || 0) }}
|
||
</p>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} /
|
||
{{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Total Cost -->
|
||
<div class="card p-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||
<Icon name="dollar" size="md" class="text-green-600 dark:text-green-400" />
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
{{ t('usage.totalCost') }}
|
||
</p>
|
||
<p class="text-xl font-bold text-green-600 dark:text-green-400">
|
||
${{ (usageStats?.total_actual_cost || 0).toFixed(4) }}
|
||
</p>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('usage.actualCost') }} /
|
||
<span class="line-through">${{ (usageStats?.total_cost || 0).toFixed(4) }}</span>
|
||
{{ t('usage.standardCost') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Average Duration -->
|
||
<div class="card p-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||
<Icon name="clock" size="md" class="text-purple-600 dark:text-purple-400" />
|
||
</div>
|
||
<div>
|
||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
{{ t('usage.avgDuration') }}
|
||
</p>
|
||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||
{{ formatDuration(usageStats?.average_duration_ms || 0) }}
|
||
</p>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.perRequest') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template #filters>
|
||
<div class="card">
|
||
<div class="px-6 py-4">
|
||
<div class="flex flex-wrap items-end gap-4">
|
||
<!-- API Key Filter -->
|
||
<div class="min-w-[180px]">
|
||
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
|
||
<Select
|
||
v-model="filters.api_key_id"
|
||
:options="apiKeyOptions"
|
||
:placeholder="t('usage.allApiKeys')"
|
||
@change="applyFilters"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Date Range Filter -->
|
||
<div>
|
||
<label class="input-label">{{ t('usage.timeRange') }}</label>
|
||
<DateRangePicker
|
||
v-model:start-date="startDate"
|
||
v-model:end-date="endDate"
|
||
@change="onDateRangeChange"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="ml-auto flex items-center gap-3">
|
||
<button @click="resetFilters" class="btn btn-secondary">
|
||
{{ t('common.reset') }}
|
||
</button>
|
||
<button @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
|
||
<svg
|
||
v-if="exporting"
|
||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle
|
||
class="opacity-25"
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
stroke-width="4"
|
||
></circle>
|
||
<path
|
||
class="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
></path>
|
||
</svg>
|
||
{{ exporting ? t('usage.exporting') : t('usage.exportCsv') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template #table>
|
||
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
||
<template #cell-api_key="{ row }">
|
||
<span class="text-sm text-gray-900 dark:text-white">{{
|
||
row.api_key?.name || '-'
|
||
}}</span>
|
||
</template>
|
||
|
||
<template #cell-model="{ value }">
|
||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||
</template>
|
||
|
||
<template #cell-reasoning_effort="{ row }">
|
||
<span class="text-sm text-gray-900 dark:text-white">
|
||
{{ formatReasoningEffort(row.reasoning_effort) }}
|
||
</span>
|
||
</template>
|
||
|
||
<template #cell-stream="{ row }">
|
||
<span
|
||
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
|
||
:class="
|
||
row.stream
|
||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||
"
|
||
>
|
||
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
|
||
</span>
|
||
</template>
|
||
|
||
<template #cell-tokens="{ row }">
|
||
<!-- 图片生成请求 -->
|
||
<div v-if="row.image_count > 0" class="flex items-center gap-1.5">
|
||
<svg
|
||
class="h-4 w-4 text-indigo-500"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||
/>
|
||
</svg>
|
||
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ $t('usage.imageUnit') }}</span>
|
||
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
|
||
</div>
|
||
<!-- Token 请求 -->
|
||
<div v-else class="flex items-center gap-1.5">
|
||
<div class="space-y-1.5 text-sm">
|
||
<!-- Input / Output Tokens -->
|
||
<div class="flex items-center gap-2">
|
||
<!-- Input -->
|
||
<div class="inline-flex items-center gap-1">
|
||
<Icon name="arrowDown" size="sm" class="text-emerald-500" />
|
||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||
row.input_tokens.toLocaleString()
|
||
}}</span>
|
||
</div>
|
||
<!-- Output -->
|
||
<div class="inline-flex items-center gap-1">
|
||
<Icon name="arrowUp" size="sm" class="text-violet-500" />
|
||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||
row.output_tokens.toLocaleString()
|
||
}}</span>
|
||
</div>
|
||
</div>
|
||
<!-- Cache Tokens (Read + Write) -->
|
||
<div
|
||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||
class="flex items-center gap-2"
|
||
>
|
||
<!-- Cache Read -->
|
||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||
<Icon name="inbox" size="sm" class="text-sky-500" />
|
||
<span class="font-medium text-sky-600 dark:text-sky-400">{{
|
||
formatCacheTokens(row.cache_read_tokens)
|
||
}}</span>
|
||
</div>
|
||
<!-- Cache Write -->
|
||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||
<Icon name="edit" size="sm" class="text-amber-500" />
|
||
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
||
formatCacheTokens(row.cache_creation_tokens)
|
||
}}</span>
|
||
<span v-if="row.cache_creation_1h_tokens > 0" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-100 text-orange-600 ring-1 ring-inset ring-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:ring-orange-500/30">1h</span>
|
||
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Token Detail Tooltip -->
|
||
<div
|
||
class="group relative"
|
||
@mouseenter="showTokenTooltip($event, row)"
|
||
@mouseleave="hideTokenTooltip"
|
||
>
|
||
<div
|
||
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
||
>
|
||
<Icon
|
||
name="infoCircle"
|
||
size="xs"
|
||
class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template #cell-cost="{ row }">
|
||
<div class="flex items-center gap-1.5 text-sm">
|
||
<span class="font-medium text-green-600 dark:text-green-400">
|
||
${{ row.actual_cost.toFixed(6) }}
|
||
</span>
|
||
<!-- Cost Detail Tooltip -->
|
||
<div
|
||
class="group relative"
|
||
@mouseenter="showTooltip($event, row)"
|
||
@mouseleave="hideTooltip"
|
||
>
|
||
<div
|
||
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
||
>
|
||
<Icon
|
||
name="infoCircle"
|
||
size="xs"
|
||
class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template #cell-first_token="{ row }">
|
||
<span
|
||
v-if="row.first_token_ms != null"
|
||
class="text-sm text-gray-600 dark:text-gray-400"
|
||
>
|
||
{{ formatDuration(row.first_token_ms) }}
|
||
</span>
|
||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||
</template>
|
||
|
||
<template #cell-duration="{ row }">
|
||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||
formatDuration(row.duration_ms)
|
||
}}</span>
|
||
</template>
|
||
|
||
<template #cell-created_at="{ value }">
|
||
<span class="text-sm text-gray-600 dark:text-gray-400">{{
|
||
formatDateTime(value)
|
||
}}</span>
|
||
</template>
|
||
|
||
<template #cell-user_agent="{ row }">
|
||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||
</template>
|
||
|
||
<template #empty>
|
||
<EmptyState :message="t('usage.noRecords')" />
|
||
</template>
|
||
</DataTable>
|
||
</template>
|
||
|
||
<template #pagination>
|
||
<Pagination
|
||
v-if="pagination.total > 0"
|
||
:page="pagination.page"
|
||
:total="pagination.total"
|
||
:page-size="pagination.page_size"
|
||
@update:page="handlePageChange"
|
||
@update:pageSize="handlePageSizeChange"
|
||
/>
|
||
</template>
|
||
</TablePageLayout>
|
||
</AppLayout>
|
||
|
||
<!-- Token Tooltip Portal -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="tokenTooltipVisible"
|
||
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||
:style="{
|
||
left: tokenTooltipPosition.x + 'px',
|
||
top: tokenTooltipPosition.y + 'px'
|
||
}"
|
||
>
|
||
<div
|
||
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||
>
|
||
<div class="space-y-1.5">
|
||
<!-- Token Breakdown -->
|
||
<div>
|
||
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
|
||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||
</div>
|
||
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||
</div>
|
||
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
|
||
<!-- 有 5m/1h 明细时,展开显示 -->
|
||
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
|
||
<div v-if="tokenTooltipData.cache_creation_5m_tokens > 0" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400 flex items-center gap-1.5">
|
||
{{ t('admin.usage.cacheCreation5mTokens') }}
|
||
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-amber-500/20 text-amber-400 ring-1 ring-inset ring-amber-500/30">5m</span>
|
||
</span>
|
||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_5m_tokens.toLocaleString() }}</span>
|
||
</div>
|
||
<div v-if="tokenTooltipData.cache_creation_1h_tokens > 0" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400 flex items-center gap-1.5">
|
||
{{ t('admin.usage.cacheCreation1hTokens') }}
|
||
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-500/20 text-orange-400 ring-1 ring-inset ring-orange-500/30">1h</span>
|
||
</span>
|
||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_1h_tokens.toLocaleString() }}</span>
|
||
</div>
|
||
</template>
|
||
<!-- 无明细时,只显示聚合值 -->
|
||
<div v-else class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="tokenTooltipData && tokenTooltipData.cache_ttl_overridden" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400 flex items-center gap-1.5">
|
||
{{ t('usage.cacheTtlOverriddenLabel') }}
|
||
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-500/20 text-rose-400 ring-1 ring-inset ring-rose-500/30">R-{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? '5m' : '1H' }}</span>
|
||
</span>
|
||
<span class="font-medium text-rose-400">{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? t('usage.cacheTtlOverridden1h') : t('usage.cacheTtlOverridden5m') }}</span>
|
||
</div>
|
||
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
|
||
</div>
|
||
</div>
|
||
<!-- Total -->
|
||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
|
||
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
|
||
</div>
|
||
</div>
|
||
<!-- Tooltip Arrow (left side) -->
|
||
<div
|
||
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- Tooltip Portal -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="tooltipVisible"
|
||
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||
:style="{
|
||
left: tooltipPosition.x + 'px',
|
||
top: tooltipPosition.y + 'px'
|
||
}"
|
||
>
|
||
<div
|
||
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||
>
|
||
<div class="space-y-1.5">
|
||
<!-- Cost Breakdown -->
|
||
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
|
||
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
||
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
|
||
</div>
|
||
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||
</div>
|
||
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
|
||
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
|
||
</div>
|
||
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
|
||
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
|
||
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
|
||
</div>
|
||
</div>
|
||
<!-- Rate and Summary -->
|
||
<div class="flex items-center justify-between gap-6">
|
||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||
<span class="font-semibold text-blue-400"
|
||
>{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span
|
||
>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-6">
|
||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||
<span class="font-medium text-white">${{ tooltipData?.total_cost.toFixed(6) }}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
||
<span class="font-semibold text-green-400"
|
||
>${{ tooltipData?.actual_cost.toFixed(6) }}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<!-- Tooltip Arrow (left side) -->
|
||
<div
|
||
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, reactive, onMounted } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useAppStore } from '@/stores/app'
|
||
import { usageAPI, keysAPI } from '@/api'
|
||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||
import DataTable from '@/components/common/DataTable.vue'
|
||
import Pagination from '@/components/common/Pagination.vue'
|
||
import EmptyState from '@/components/common/EmptyState.vue'
|
||
import Select from '@/components/common/Select.vue'
|
||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||
import Icon from '@/components/icons/Icon.vue'
|
||
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
||
import type { Column } from '@/components/common/types'
|
||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||
|
||
const { t } = useI18n()
|
||
const appStore = useAppStore()
|
||
|
||
let abortController: AbortController | null = null
|
||
|
||
// Tooltip state
|
||
const tooltipVisible = ref(false)
|
||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||
const tooltipData = ref<UsageLog | null>(null)
|
||
|
||
// Token tooltip state
|
||
const tokenTooltipVisible = ref(false)
|
||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||
const tokenTooltipData = ref<UsageLog | null>(null)
|
||
|
||
// Usage stats from API
|
||
const usageStats = ref<UsageStatsResponse | null>(null)
|
||
|
||
const columns = computed<Column[]>(() => [
|
||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||
{ key: 'model', label: t('usage.model'), sortable: true },
|
||
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
|
||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false }
|
||
])
|
||
|
||
const usageLogs = ref<UsageLog[]>([])
|
||
const apiKeys = ref<ApiKey[]>([])
|
||
const loading = ref(false)
|
||
const exporting = ref(false)
|
||
|
||
const apiKeyOptions = computed(() => {
|
||
return [
|
||
{ value: null, label: t('usage.allApiKeys') },
|
||
...apiKeys.value.map((key) => ({
|
||
value: key.id,
|
||
label: key.name
|
||
}))
|
||
]
|
||
})
|
||
|
||
// Helper function to format date in local timezone
|
||
const formatLocalDate = (date: Date): string => {
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
// Initialize date range immediately
|
||
const now = new Date()
|
||
const weekAgo = new Date(now)
|
||
weekAgo.setDate(weekAgo.getDate() - 6)
|
||
|
||
// Date range state
|
||
const startDate = ref(formatLocalDate(weekAgo))
|
||
const endDate = ref(formatLocalDate(now))
|
||
|
||
const filters = ref<UsageQueryParams>({
|
||
api_key_id: undefined,
|
||
start_date: undefined,
|
||
end_date: undefined
|
||
})
|
||
|
||
// Initialize filters with date range
|
||
filters.value.start_date = startDate.value
|
||
filters.value.end_date = endDate.value
|
||
|
||
// Handle date range change from DateRangePicker
|
||
const onDateRangeChange = (range: {
|
||
startDate: string
|
||
endDate: string
|
||
preset: string | null
|
||
}) => {
|
||
filters.value.start_date = range.startDate
|
||
filters.value.end_date = range.endDate
|
||
applyFilters()
|
||
}
|
||
|
||
const pagination = reactive({
|
||
page: 1,
|
||
page_size: 20,
|
||
total: 0,
|
||
pages: 0
|
||
})
|
||
|
||
const formatDuration = (ms: number): string => {
|
||
if (ms < 1000) return `${ms.toFixed(0)}ms`
|
||
return `${(ms / 1000).toFixed(2)}s`
|
||
}
|
||
|
||
const formatUserAgent = (ua: string): string => {
|
||
// 提取主要客户端标识
|
||
if (ua.includes('claude-cli')) return ua.match(/claude-cli\/[\d.]+/)?.[0] || 'Claude CLI'
|
||
if (ua.includes('Cursor')) return 'Cursor'
|
||
if (ua.includes('VSCode') || ua.includes('vscode')) return 'VS Code'
|
||
if (ua.includes('Continue')) return 'Continue'
|
||
if (ua.includes('Cline')) return 'Cline'
|
||
if (ua.includes('OpenAI')) return 'OpenAI SDK'
|
||
if (ua.includes('anthropic')) return 'Anthropic SDK'
|
||
// 截断过长的 UA
|
||
return ua.length > 30 ? ua.substring(0, 30) + '...' : ua
|
||
}
|
||
|
||
const formatTokens = (value: number): string => {
|
||
if (value >= 1_000_000_000) {
|
||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||
} else if (value >= 1_000_000) {
|
||
return `${(value / 1_000_000).toFixed(2)}M`
|
||
} else if (value >= 1_000) {
|
||
return `${(value / 1_000).toFixed(2)}K`
|
||
}
|
||
return value.toLocaleString()
|
||
}
|
||
|
||
// Compact format for cache tokens in table cells
|
||
const formatCacheTokens = (value: number): string => {
|
||
if (value >= 1_000_000) {
|
||
return `${(value / 1_000_000).toFixed(1)}M`
|
||
} else if (value >= 1_000) {
|
||
return `${(value / 1_000).toFixed(1)}K`
|
||
}
|
||
return value.toLocaleString()
|
||
}
|
||
|
||
const loadUsageLogs = async () => {
|
||
if (abortController) {
|
||
abortController.abort()
|
||
}
|
||
const currentAbortController = new AbortController()
|
||
abortController = currentAbortController
|
||
const { signal } = currentAbortController
|
||
loading.value = true
|
||
try {
|
||
const params: UsageQueryParams = {
|
||
page: pagination.page,
|
||
page_size: pagination.page_size,
|
||
...filters.value
|
||
}
|
||
|
||
const response = await usageAPI.query(params, { signal })
|
||
if (signal.aborted) {
|
||
return
|
||
}
|
||
usageLogs.value = response.items
|
||
pagination.total = response.total
|
||
pagination.pages = response.pages
|
||
} catch (error) {
|
||
if (signal.aborted) {
|
||
return
|
||
}
|
||
const abortError = error as { name?: string; code?: string }
|
||
if (abortError?.name === 'AbortError' || abortError?.code === 'ERR_CANCELED') {
|
||
return
|
||
}
|
||
appStore.showError(t('usage.failedToLoad'))
|
||
} finally {
|
||
if (abortController === currentAbortController) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadApiKeys = async () => {
|
||
try {
|
||
const response = await keysAPI.list(1, 100)
|
||
apiKeys.value = response.items
|
||
} catch (error) {
|
||
console.error('Failed to load API keys:', error)
|
||
}
|
||
}
|
||
|
||
const loadUsageStats = async () => {
|
||
try {
|
||
const apiKeyId = filters.value.api_key_id ? Number(filters.value.api_key_id) : undefined
|
||
const stats = await usageAPI.getStatsByDateRange(
|
||
filters.value.start_date || startDate.value,
|
||
filters.value.end_date || endDate.value,
|
||
apiKeyId
|
||
)
|
||
usageStats.value = stats
|
||
} catch (error) {
|
||
console.error('Failed to load usage stats:', error)
|
||
}
|
||
}
|
||
|
||
const applyFilters = () => {
|
||
pagination.page = 1
|
||
loadUsageLogs()
|
||
loadUsageStats()
|
||
}
|
||
|
||
const resetFilters = () => {
|
||
filters.value = {
|
||
api_key_id: undefined,
|
||
start_date: undefined,
|
||
end_date: undefined
|
||
}
|
||
// Reset date range to default (last 7 days)
|
||
const now = new Date()
|
||
const weekAgo = new Date(now)
|
||
weekAgo.setDate(weekAgo.getDate() - 6)
|
||
startDate.value = formatLocalDate(weekAgo)
|
||
endDate.value = formatLocalDate(now)
|
||
filters.value.start_date = startDate.value
|
||
filters.value.end_date = endDate.value
|
||
pagination.page = 1
|
||
loadUsageLogs()
|
||
loadUsageStats()
|
||
}
|
||
|
||
const handlePageChange = (page: number) => {
|
||
pagination.page = page
|
||
loadUsageLogs()
|
||
}
|
||
|
||
const handlePageSizeChange = (pageSize: number) => {
|
||
pagination.page_size = pageSize
|
||
pagination.page = 1
|
||
loadUsageLogs()
|
||
}
|
||
|
||
/**
|
||
* Escape CSV value to prevent injection and handle special characters
|
||
*/
|
||
const escapeCSVValue = (value: unknown): string => {
|
||
if (value == null) return ''
|
||
|
||
const str = String(value)
|
||
const escaped = str.replace(/"/g, '""')
|
||
|
||
// Prevent formula injection by prefixing dangerous characters with single quote
|
||
if (/^[=+\-@\t\r]/.test(str)) {
|
||
return `"\'${escaped}"`
|
||
}
|
||
|
||
// Escape values containing comma, quote, or newline
|
||
if (/[,"\n\r]/.test(str)) {
|
||
return `"${escaped}"`
|
||
}
|
||
|
||
return str
|
||
}
|
||
|
||
const exportToCSV = async () => {
|
||
if (pagination.total === 0) {
|
||
appStore.showWarning(t('usage.noDataToExport'))
|
||
return
|
||
}
|
||
|
||
exporting.value = true
|
||
appStore.showInfo(t('usage.preparingExport'))
|
||
|
||
try {
|
||
const allLogs: UsageLog[] = []
|
||
const pageSize = 100 // Use a larger page size for export to reduce requests
|
||
const totalRequests = Math.ceil(pagination.total / pageSize)
|
||
|
||
for (let page = 1; page <= totalRequests; page++) {
|
||
const params: UsageQueryParams = {
|
||
page: page,
|
||
page_size: pageSize,
|
||
...filters.value
|
||
}
|
||
const response = await usageAPI.query(params)
|
||
allLogs.push(...response.items)
|
||
}
|
||
|
||
if (allLogs.length === 0) {
|
||
appStore.showWarning(t('usage.noDataToExport'))
|
||
return
|
||
}
|
||
|
||
const headers = [
|
||
'Time',
|
||
'API Key Name',
|
||
'Model',
|
||
'Reasoning Effort',
|
||
'Type',
|
||
'Input Tokens',
|
||
'Output Tokens',
|
||
'Cache Read Tokens',
|
||
'Cache Creation Tokens',
|
||
'Rate Multiplier',
|
||
'Billed Cost',
|
||
'Original Cost',
|
||
'First Token (ms)',
|
||
'Duration (ms)'
|
||
]
|
||
const rows = allLogs.map((log) =>
|
||
[
|
||
log.created_at,
|
||
log.api_key?.name || '',
|
||
log.model,
|
||
formatReasoningEffort(log.reasoning_effort),
|
||
log.stream ? 'Stream' : 'Sync',
|
||
log.input_tokens,
|
||
log.output_tokens,
|
||
log.cache_read_tokens,
|
||
log.cache_creation_tokens,
|
||
log.rate_multiplier,
|
||
log.actual_cost.toFixed(8),
|
||
log.total_cost.toFixed(8),
|
||
log.first_token_ms ?? '',
|
||
log.duration_ms
|
||
].map(escapeCSVValue)
|
||
)
|
||
|
||
const csvContent = [
|
||
headers.map(escapeCSVValue).join(','),
|
||
...rows.map((row) => row.join(','))
|
||
].join('\n')
|
||
|
||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||
const url = window.URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `usage_${filters.value.start_date}_to_${filters.value.end_date}.csv`
|
||
link.click()
|
||
window.URL.revokeObjectURL(url)
|
||
|
||
appStore.showSuccess(t('usage.exportSuccess'))
|
||
} catch (error) {
|
||
appStore.showError(t('usage.exportFailed'))
|
||
console.error('CSV Export failed:', error)
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
// Tooltip functions
|
||
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
||
const target = event.currentTarget as HTMLElement
|
||
const rect = target.getBoundingClientRect()
|
||
|
||
tooltipData.value = row
|
||
// Position to the right of the icon, vertically centered
|
||
tooltipPosition.value.x = rect.right + 8
|
||
tooltipPosition.value.y = rect.top + rect.height / 2
|
||
tooltipVisible.value = true
|
||
}
|
||
|
||
const hideTooltip = () => {
|
||
tooltipVisible.value = false
|
||
tooltipData.value = null
|
||
}
|
||
|
||
// Token tooltip functions
|
||
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||
const target = event.currentTarget as HTMLElement
|
||
const rect = target.getBoundingClientRect()
|
||
|
||
tokenTooltipData.value = row
|
||
tokenTooltipPosition.value.x = rect.right + 8
|
||
tokenTooltipPosition.value.y = rect.top + rect.height / 2
|
||
tokenTooltipVisible.value = true
|
||
}
|
||
|
||
const hideTokenTooltip = () => {
|
||
tokenTooltipVisible.value = false
|
||
tokenTooltipData.value = null
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadApiKeys()
|
||
loadUsageLogs()
|
||
loadUsageStats()
|
||
})
|
||
</script>
|