452 lines
16 KiB
Vue
452 lines
16 KiB
Vue
<template>
|
|
<div class="card p-4">
|
|
<div class="mb-4 flex items-center justify-between gap-3">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{{ !enableRankingView || activeView === 'model_distribution'
|
|
? t('admin.dashboard.modelDistribution')
|
|
: t('admin.dashboard.spendingRankingTitle') }}
|
|
</h3>
|
|
<div class="flex items-center gap-2">
|
|
<div
|
|
v-if="showMetricToggle"
|
|
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
|
:class="metric === 'tokens'
|
|
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
|
@click="emit('update:metric', 'tokens')"
|
|
>
|
|
{{ t('admin.dashboard.metricTokens') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
|
:class="metric === 'actual_cost'
|
|
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
|
@click="emit('update:metric', 'actual_cost')"
|
|
>
|
|
{{ t('admin.dashboard.metricActualCost') }}
|
|
</button>
|
|
</div>
|
|
<div v-if="enableRankingView" class="inline-flex rounded-lg bg-gray-100 p-1 dark:bg-dark-800">
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
|
:class="
|
|
activeView === 'model_distribution'
|
|
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
|
"
|
|
@click="activeView = 'model_distribution'"
|
|
>
|
|
{{ t('admin.dashboard.viewModelDistribution') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
|
|
:class="
|
|
activeView === 'spending_ranking'
|
|
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
|
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
|
"
|
|
@click="activeView = 'spending_ranking'"
|
|
>
|
|
{{ t('admin.dashboard.viewSpendingRanking') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="activeView === 'model_distribution' && loading" class="flex h-48 items-center justify-center">
|
|
<LoadingSpinner />
|
|
</div>
|
|
<div
|
|
v-else-if="activeView === 'model_distribution' && displayModelStats.length > 0 && chartData"
|
|
class="flex items-center gap-6"
|
|
>
|
|
<div class="h-48 w-48">
|
|
<Doughnut :data="chartData" :options="doughnutOptions" />
|
|
</div>
|
|
<div class="max-h-48 flex-1 overflow-y-auto">
|
|
<table class="w-full text-xs">
|
|
<thead>
|
|
<tr class="text-gray-500 dark:text-gray-400">
|
|
<th class="pb-2 text-left">{{ t('admin.dashboard.model') }}</th>
|
|
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
|
|
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
|
|
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
|
|
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="model in displayModelStats" :key="model.model">
|
|
<tr
|
|
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
|
|
@click="toggleBreakdown('model', model.model)"
|
|
>
|
|
<td
|
|
class="max-w-[100px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
:title="model.model"
|
|
>
|
|
<span class="inline-flex items-center gap-1">
|
|
<svg v-if="expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
|
{{ model.model }}
|
|
</span>
|
|
</td>
|
|
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
|
{{ formatNumber(model.requests) }}
|
|
</td>
|
|
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
|
{{ formatTokens(model.total_tokens) }}
|
|
</td>
|
|
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
|
${{ formatCost(model.actual_cost) }}
|
|
</td>
|
|
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
|
${{ formatCost(model.cost) }}
|
|
</td>
|
|
</tr>
|
|
<tr v-if="expandedKey === `model-${model.model}`">
|
|
<td colspan="5" class="p-0">
|
|
<UserBreakdownSubTable
|
|
:items="breakdownItems"
|
|
:loading="breakdownLoading"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else-if="activeView === 'model_distribution'"
|
|
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
|
>
|
|
{{ t('admin.dashboard.noDataAvailable') }}
|
|
</div>
|
|
|
|
<div v-else-if="rankingLoading" class="flex h-48 items-center justify-center">
|
|
<LoadingSpinner />
|
|
</div>
|
|
<div
|
|
v-else-if="rankingError"
|
|
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
|
>
|
|
{{ t('admin.dashboard.failedToLoad') }}
|
|
</div>
|
|
<div v-else-if="rankingDisplayItems.length > 0 && rankingChartData" class="flex items-center gap-6">
|
|
<div class="h-48 w-48">
|
|
<Doughnut :data="rankingChartData" :options="rankingDoughnutOptions" />
|
|
</div>
|
|
<div class="max-h-48 flex-1 overflow-y-auto">
|
|
<table class="w-full text-xs">
|
|
<thead>
|
|
<tr class="text-gray-500 dark:text-gray-400">
|
|
<th class="pb-2 text-left">{{ t('admin.dashboard.spendingRankingUser') }}</th>
|
|
<th class="pb-2 text-right">{{ t('admin.dashboard.spendingRankingRequests') }}</th>
|
|
<th class="pb-2 text-right">{{ t('admin.dashboard.spendingRankingTokens') }}</th>
|
|
<th class="pb-2 text-right">{{ t('admin.dashboard.spendingRankingSpend') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="(item, index) in rankingDisplayItems"
|
|
:key="item.isOther ? 'others' : `${item.user_id}-${index}`"
|
|
class="border-t border-gray-100 transition-colors dark:border-gray-700"
|
|
:class="item.isOther
|
|
? 'bg-gray-50/70 dark:bg-dark-700/20'
|
|
: 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40'"
|
|
@click="item.isOther ? undefined : emit('ranking-click', item)"
|
|
>
|
|
<td class="py-1.5">
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<span class="shrink-0 text-[11px] font-semibold text-gray-500 dark:text-gray-400">
|
|
{{ item.isOther ? 'Σ' : `#${index + 1}` }}
|
|
</span>
|
|
<span
|
|
class="block max-w-[140px] truncate font-medium text-gray-900 dark:text-white"
|
|
:title="getRankingRowLabel(item)"
|
|
>
|
|
{{ getRankingRowLabel(item) }}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
|
{{ formatNumber(item.requests) }}
|
|
</td>
|
|
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
|
{{ formatTokens(item.tokens) }}
|
|
</td>
|
|
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
|
${{ formatCost(item.actual_cost) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
|
>
|
|
{{ t('admin.dashboard.noDataAvailable') }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
|
|
import { Doughnut } from 'vue-chartjs'
|
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
|
import UserBreakdownSubTable from './UserBreakdownSubTable.vue'
|
|
import type { ModelStat, UserSpendingRankingItem, UserBreakdownItem } from '@/types'
|
|
import { getUserBreakdown } from '@/api/admin/dashboard'
|
|
|
|
ChartJS.register(ArcElement, Tooltip, Legend)
|
|
|
|
const { t } = useI18n()
|
|
|
|
type DistributionMetric = 'tokens' | 'actual_cost'
|
|
type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean }
|
|
const props = withDefaults(defineProps<{
|
|
modelStats: ModelStat[]
|
|
enableRankingView?: boolean
|
|
rankingItems?: UserSpendingRankingItem[]
|
|
rankingTotalActualCost?: number
|
|
rankingTotalRequests?: number
|
|
rankingTotalTokens?: number
|
|
loading?: boolean
|
|
metric?: DistributionMetric
|
|
showMetricToggle?: boolean
|
|
rankingLoading?: boolean
|
|
rankingError?: boolean
|
|
startDate?: string
|
|
endDate?: string
|
|
}>(), {
|
|
enableRankingView: false,
|
|
rankingItems: () => [],
|
|
rankingTotalActualCost: 0,
|
|
rankingTotalRequests: 0,
|
|
rankingTotalTokens: 0,
|
|
loading: false,
|
|
metric: 'tokens',
|
|
showMetricToggle: false,
|
|
rankingLoading: false,
|
|
rankingError: false
|
|
})
|
|
|
|
const expandedKey = ref<string | null>(null)
|
|
const breakdownItems = ref<UserBreakdownItem[]>([])
|
|
const breakdownLoading = ref(false)
|
|
|
|
const toggleBreakdown = async (type: string, id: string) => {
|
|
const key = `${type}-${id}`
|
|
if (expandedKey.value === key) {
|
|
expandedKey.value = null
|
|
return
|
|
}
|
|
expandedKey.value = key
|
|
breakdownLoading.value = true
|
|
breakdownItems.value = []
|
|
try {
|
|
const res = await getUserBreakdown({
|
|
start_date: props.startDate,
|
|
end_date: props.endDate,
|
|
model: id,
|
|
})
|
|
breakdownItems.value = res.users || []
|
|
} catch {
|
|
breakdownItems.value = []
|
|
} finally {
|
|
breakdownLoading.value = false
|
|
}
|
|
}
|
|
|
|
const emit = defineEmits<{
|
|
'update:metric': [value: DistributionMetric]
|
|
'ranking-click': [item: UserSpendingRankingItem]
|
|
}>()
|
|
|
|
const enableRankingView = computed(() => props.enableRankingView)
|
|
const activeView = ref<'model_distribution' | 'spending_ranking'>('model_distribution')
|
|
|
|
const chartColors = [
|
|
'#3b82f6',
|
|
'#10b981',
|
|
'#f59e0b',
|
|
'#ef4444',
|
|
'#8b5cf6',
|
|
'#ec4899',
|
|
'#14b8a6',
|
|
'#f97316',
|
|
'#6366f1',
|
|
'#84cc16',
|
|
'#06b6d4',
|
|
'#a855f7'
|
|
]
|
|
|
|
const displayModelStats = computed(() => {
|
|
if (!props.modelStats?.length) return []
|
|
|
|
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
|
|
return [...props.modelStats].sort((a, b) => b[metricKey] - a[metricKey])
|
|
})
|
|
|
|
const chartData = computed(() => {
|
|
if (!props.modelStats?.length) return null
|
|
|
|
return {
|
|
labels: displayModelStats.value.map((m) => m.model),
|
|
datasets: [
|
|
{
|
|
data: displayModelStats.value.map((m) => props.metric === 'actual_cost' ? m.actual_cost : m.total_tokens),
|
|
backgroundColor: chartColors.slice(0, displayModelStats.value.length),
|
|
borderWidth: 0
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
const rankingChartData = computed(() => {
|
|
if (!props.rankingItems?.length) return null
|
|
|
|
const labels = props.rankingItems.map((item, index) => `#${index + 1} ${getRankingUserLabel(item)}`)
|
|
const data = props.rankingItems.map((item) => item.actual_cost)
|
|
const backgroundColor = chartColors.slice(0, props.rankingItems.length)
|
|
|
|
if (otherRankingItem.value) {
|
|
labels.push(t('admin.dashboard.spendingRankingOther'))
|
|
data.push(otherRankingItem.value.actual_cost)
|
|
backgroundColor.push('#94a3b8')
|
|
}
|
|
|
|
return {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
data,
|
|
backgroundColor,
|
|
borderWidth: 0
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
const otherRankingItem = computed<RankingDisplayItem | null>(() => {
|
|
if (!props.rankingItems?.length) return null
|
|
|
|
const rankedActualCost = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0)
|
|
const rankedRequests = props.rankingItems.reduce((sum, item) => sum + item.requests, 0)
|
|
const rankedTokens = props.rankingItems.reduce((sum, item) => sum + item.tokens, 0)
|
|
|
|
const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedActualCost, 0)
|
|
const otherRequests = Math.max((props.rankingTotalRequests || 0) - rankedRequests, 0)
|
|
const otherTokens = Math.max((props.rankingTotalTokens || 0) - rankedTokens, 0)
|
|
|
|
if (otherActualCost <= 0.000001 && otherRequests <= 0 && otherTokens <= 0) return null
|
|
|
|
return {
|
|
user_id: 0,
|
|
email: '',
|
|
actual_cost: otherActualCost,
|
|
requests: otherRequests,
|
|
tokens: otherTokens,
|
|
isOther: true
|
|
}
|
|
})
|
|
|
|
const rankingDisplayItems = computed<RankingDisplayItem[]>(() => {
|
|
if (!props.rankingItems?.length) return []
|
|
return otherRankingItem.value
|
|
? [...props.rankingItems, otherRankingItem.value]
|
|
: [...props.rankingItems]
|
|
})
|
|
|
|
const doughnutOptions = computed(() => ({
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context: any) => {
|
|
const value = context.raw as number
|
|
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
|
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
|
|
const formattedValue = props.metric === 'actual_cost'
|
|
? `$${formatCost(value)}`
|
|
: formatTokens(value)
|
|
return `${context.label}: ${formattedValue} (${percentage}%)`
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
|
|
const rankingDoughnutOptions = computed(() => ({
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context: any) => {
|
|
const value = context.raw as number
|
|
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
|
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
|
|
return `${context.label}: $${formatCost(value)} (${percentage}%)`
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
|
|
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()
|
|
}
|
|
|
|
const formatNumber = (value: number): string => {
|
|
return value.toLocaleString()
|
|
}
|
|
|
|
const getRankingUserLabel = (item: UserSpendingRankingItem): string => {
|
|
if (item.email) return item.email
|
|
return t('admin.redeem.userPrefix', { id: item.user_id })
|
|
}
|
|
|
|
const getRankingRowLabel = (item: RankingDisplayItem): string => {
|
|
if (item.isOther) return t('admin.dashboard.spendingRankingOther')
|
|
return getRankingUserLabel(item)
|
|
}
|
|
|
|
const formatCost = (value: number): string => {
|
|
if (value >= 1000) {
|
|
return (value / 1000).toFixed(2) + 'K'
|
|
} else if (value >= 1) {
|
|
return value.toFixed(2)
|
|
} else if (value >= 0.01) {
|
|
return value.toFixed(3)
|
|
}
|
|
return value.toFixed(4)
|
|
}
|
|
</script>
|