- 将 usage log DTO 拆分为用户/管理员两类 - 用户接口不返回 account_rate_multiplier/ip_address/account - 管理员接口保留管理员字段 - 补充契约测试防止回归
328 lines
17 KiB
Vue
328 lines
17 KiB
Vue
<template>
|
|
<div class="card overflow-hidden">
|
|
<div class="overflow-auto">
|
|
<DataTable :columns="cols" :data="data" :loading="loading">
|
|
<template #cell-user="{ row }">
|
|
<div class="text-sm">
|
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
|
|
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-api_key="{ row }">
|
|
<span class="text-sm text-gray-900 dark:text-white">{{ row.api_key?.name || '-' }}</span>
|
|
</template>
|
|
|
|
<template #cell-account="{ row }">
|
|
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
|
|
</template>
|
|
|
|
<template #cell-model="{ value }">
|
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
|
</template>
|
|
|
|
<template #cell-group="{ row }">
|
|
<span v-if="row.group" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200">
|
|
{{ row.group.name }}
|
|
</span>
|
|
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</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 text-sm">
|
|
<div class="flex items-center gap-2">
|
|
<div class="inline-flex items-center gap-1">
|
|
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
|
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
|
|
</div>
|
|
<div class="inline-flex items-center gap-1">
|
|
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
|
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
|
|
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
|
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
|
|
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
|
|
</div>
|
|
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
|
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
|
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</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="text-sm">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</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>
|
|
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
|
|
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
|
|
</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 #cell-ip_address="{ row }">
|
|
<span v-if="row.ip_address" class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.ip_address }}</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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<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" 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 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>
|
|
<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>
|
|
<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>
|
|
|
|
<!-- Cost 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.accountMultiplier') }}</span>
|
|
<span class="font-semibold text-blue-400">{{ (tooltipData?.account_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) || '0.000000' }}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-6">
|
|
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
|
|
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</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.accountBilled') }}</span>
|
|
<span class="font-semibold text-green-400">
|
|
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<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 } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { formatDateTime } from '@/utils/format'
|
|
import DataTable from '@/components/common/DataTable.vue'
|
|
import EmptyState from '@/components/common/EmptyState.vue'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import type { AdminUsageLog } from '@/types'
|
|
|
|
defineProps(['data', 'loading'])
|
|
const { t } = useI18n()
|
|
|
|
// Tooltip state - cost
|
|
const tooltipVisible = ref(false)
|
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
|
const tooltipData = ref<AdminUsageLog | null>(null)
|
|
|
|
// Tooltip state - token
|
|
const tokenTooltipVisible = ref(false)
|
|
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
|
const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
|
|
|
const cols = computed(() => [
|
|
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
|
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
|
{ key: 'account', label: t('admin.usage.account'), sortable: false },
|
|
{ key: 'model', label: t('usage.model'), sortable: true },
|
|
{ key: 'group', label: t('admin.usage.group'), 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 },
|
|
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
|
|
])
|
|
|
|
const formatCacheTokens = (tokens: number): string => {
|
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
|
return tokens.toString()
|
|
}
|
|
|
|
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 formatDuration = (ms: number | null | undefined): string => {
|
|
if (ms == null) return '-'
|
|
if (ms < 1000) return `${ms}ms`
|
|
return `${(ms / 1000).toFixed(2)}s`
|
|
}
|
|
|
|
// Cost tooltip functions
|
|
const showTooltip = (event: MouseEvent, row: AdminUsageLog) => {
|
|
const target = event.currentTarget as HTMLElement
|
|
const rect = target.getBoundingClientRect()
|
|
tooltipData.value = row
|
|
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: AdminUsageLog) => {
|
|
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
|
|
}
|
|
</script>
|