merge: 合并 upstream/main 并保留本地图片计费功能
This commit is contained in:
16
frontend/src/components/admin/usage/UsageExportProgress.vue
Normal file
16
frontend/src/components/admin/usage/UsageExportProgress.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<ExportProgressDialog
|
||||
:show="show"
|
||||
:progress="progress"
|
||||
:current="current"
|
||||
:total="total"
|
||||
:estimated-time="estimatedTime"
|
||||
@cancel="$emit('cancel')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
|
||||
defineProps<{ show: boolean, progress: number, current: number, total: number, estimatedTime: string }>()
|
||||
defineEmits(['cancel'])
|
||||
</script>
|
||||
353
frontend/src/components/admin/usage/UsageFilters.vue
Normal file
353
frontend/src/components/admin/usage/UsageFilters.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<!-- Toolbar: left filters (multi-line) + right actions -->
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<!-- Left: filters (allowed to wrap to multiple rows) -->
|
||||
<div class="flex flex-1 flex-wrap items-end gap-4">
|
||||
<!-- User Search -->
|
||||
<div ref="userSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
|
||||
<label class="input-label">{{ t('admin.usage.userFilter') }}</label>
|
||||
<input
|
||||
v-model="userKeyword"
|
||||
type="text"
|
||||
class="input pr-8"
|
||||
:placeholder="t('admin.usage.searchUserPlaceholder')"
|
||||
@input="debounceUserSearch"
|
||||
@focus="showUserDropdown = true"
|
||||
/>
|
||||
<button
|
||||
v-if="filters.user_id"
|
||||
type="button"
|
||||
@click="clearUser"
|
||||
class="absolute right-2 top-9 text-gray-400"
|
||||
aria-label="Clear user filter"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
v-if="showUserDropdown && (userResults.length > 0 || userKeyword)"
|
||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
v-for="u in userResults"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
@click="selectUser(u)"
|
||||
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ u.email }}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">#{{ u.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key Search -->
|
||||
<div ref="apiKeySearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
|
||||
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
|
||||
<input
|
||||
v-model="apiKeyKeyword"
|
||||
type="text"
|
||||
class="input pr-8"
|
||||
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
|
||||
@input="debounceApiKeySearch"
|
||||
@focus="showApiKeyDropdown = true"
|
||||
/>
|
||||
<button
|
||||
v-if="filters.api_key_id"
|
||||
type="button"
|
||||
@click="onClearApiKey"
|
||||
class="absolute right-2 top-9 text-gray-400"
|
||||
aria-label="Clear API key filter"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
|
||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
v-for="k in apiKeyResults"
|
||||
:key="k.id"
|
||||
type="button"
|
||||
@click="selectApiKey(k)"
|
||||
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span class="truncate">{{ k.name || `#${k.id}` }}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">#{{ k.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[220px]">
|
||||
<label class="input-label">{{ t('usage.model') }}</label>
|
||||
<Select v-model="filters.model" :options="modelOptions" searchable @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Account Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[220px]">
|
||||
<label class="input-label">{{ t('admin.usage.account') }}</label>
|
||||
<Select v-model="filters.account_id" :options="accountOptions" searchable @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Stream Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.type') }}</label>
|
||||
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.billingType') }}</label>
|
||||
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Group Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.group') }}</label>
|
||||
<Select v-model="filters.group_id" :options="groupOptions" searchable @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="w-full sm:w-auto [&_.date-picker-trigger]:w-full">
|
||||
<label class="input-label">{{ t('usage.timeRange') }}</label>
|
||||
<DateRangePicker
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
@update:startDate="updateStartDate"
|
||||
@update:endDate="updateEndDate"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
|
||||
{{ t('usage.exportExcel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import Select, { type SelectOption } from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage'
|
||||
|
||||
type ModelValue = Record<string, any>
|
||||
|
||||
interface Props {
|
||||
modelValue: ModelValue
|
||||
exporting: boolean
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'update:startDate',
|
||||
'update:endDate',
|
||||
'change',
|
||||
'reset',
|
||||
'export'
|
||||
])
|
||||
|
||||
const { t } = useI18n()
|
||||
const filters = toRef(props, 'modelValue')
|
||||
|
||||
const userSearchRef = ref<HTMLElement | null>(null)
|
||||
const apiKeySearchRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const userKeyword = ref('')
|
||||
const userResults = ref<SimpleUser[]>([])
|
||||
const showUserDropdown = ref(false)
|
||||
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const apiKeyKeyword = ref('')
|
||||
const apiKeyResults = ref<SimpleApiKey[]>([])
|
||||
const showApiKeyDropdown = ref(false)
|
||||
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
|
||||
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
|
||||
const accountOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allAccounts') }])
|
||||
|
||||
const streamTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allTypes') },
|
||||
{ value: true, label: t('usage.stream') },
|
||||
{ value: false, label: t('usage.sync') }
|
||||
])
|
||||
|
||||
const billingTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allBillingTypes') },
|
||||
{ value: 1, label: t('usage.subscription') },
|
||||
{ value: 0, label: t('usage.balance') }
|
||||
])
|
||||
|
||||
const emitChange = () => emit('change')
|
||||
|
||||
const updateStartDate = (value: string) => {
|
||||
emit('update:startDate', value)
|
||||
filters.value.start_date = value
|
||||
}
|
||||
|
||||
const updateEndDate = (value: string) => {
|
||||
emit('update:endDate', value)
|
||||
filters.value.end_date = value
|
||||
}
|
||||
|
||||
const debounceUserSearch = () => {
|
||||
if (userSearchTimeout) clearTimeout(userSearchTimeout)
|
||||
userSearchTimeout = setTimeout(async () => {
|
||||
if (!userKeyword.value) {
|
||||
userResults.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
userResults.value = await adminAPI.usage.searchUsers(userKeyword.value)
|
||||
} catch {
|
||||
userResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const debounceApiKeySearch = () => {
|
||||
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
|
||||
apiKeySearchTimeout = setTimeout(async () => {
|
||||
if (!apiKeyKeyword.value) {
|
||||
apiKeyResults.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
|
||||
filters.value.user_id,
|
||||
apiKeyKeyword.value
|
||||
)
|
||||
} catch {
|
||||
apiKeyResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const selectUser = (u: SimpleUser) => {
|
||||
userKeyword.value = u.email
|
||||
showUserDropdown.value = false
|
||||
filters.value.user_id = u.id
|
||||
clearApiKey()
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const clearUser = () => {
|
||||
userKeyword.value = ''
|
||||
userResults.value = []
|
||||
showUserDropdown.value = false
|
||||
filters.value.user_id = undefined
|
||||
clearApiKey()
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const selectApiKey = (k: SimpleApiKey) => {
|
||||
apiKeyKeyword.value = k.name || String(k.id)
|
||||
showApiKeyDropdown.value = false
|
||||
filters.value.api_key_id = k.id
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const clearApiKey = () => {
|
||||
apiKeyKeyword.value = ''
|
||||
apiKeyResults.value = []
|
||||
showApiKeyDropdown.value = false
|
||||
filters.value.api_key_id = undefined
|
||||
}
|
||||
|
||||
const onClearApiKey = () => {
|
||||
clearApiKey()
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const onDocumentClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null
|
||||
if (!target) return
|
||||
|
||||
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
|
||||
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
|
||||
|
||||
if (!clickedInsideUser) showUserDropdown.value = false
|
||||
if (!clickedInsideApiKey) showApiKeyDropdown.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(value) => {
|
||||
filters.value.start_date = value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.endDate,
|
||||
(value) => {
|
||||
filters.value.end_date = value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.value.user_id,
|
||||
(userId) => {
|
||||
if (!userId) {
|
||||
userKeyword.value = ''
|
||||
userResults.value = []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.value.api_key_id,
|
||||
(apiKeyId) => {
|
||||
if (!apiKeyId) {
|
||||
apiKeyKeyword.value = ''
|
||||
apiKeyResults.value = []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
|
||||
try {
|
||||
const [gs, ms, as] = await Promise.all([
|
||||
adminAPI.groups.list(1, 1000),
|
||||
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }),
|
||||
adminAPI.accounts.list(1, 1000)
|
||||
])
|
||||
|
||||
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
|
||||
|
||||
accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name })))
|
||||
|
||||
const uniqueModels = new Set<string>()
|
||||
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
|
||||
modelOptions.value.push(
|
||||
...Array.from(uniqueModels)
|
||||
.sort()
|
||||
.map((m) => ({ value: m, label: m }))
|
||||
)
|
||||
} catch {
|
||||
// Ignore filter option loading errors (page still usable)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
</script>
|
||||
46
frontend/src/components/admin/usage/UsageStatsCards.vue
Normal file
46
frontend/src/components/admin/usage/UsageStatsCards.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<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></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></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><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></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 { useI18n } from 'vue-i18n'
|
||||
import type { AdminUsageStatsResponse } from '@/api/admin/usage'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
defineProps<{ stats: AdminUsageStatsResponse | null }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
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()
|
||||
}
|
||||
</script>
|
||||
163
frontend/src/components/admin/usage/UsageTable.vue
Normal file
163
frontend/src/components/admin/usage/UsageTable.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<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="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>
|
||||
</template>
|
||||
|
||||
<template #cell-cost="{ row }">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-billing_type="{ row }">
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.billing_type === 1 ? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'">
|
||||
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
|
||||
</span>
|
||||
</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-request_id="{ row }">
|
||||
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]">
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate" :title="row.request_id">{{ row.request_id }}</span>
|
||||
<button @click="copyRequestId(row.request_id)" class="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="copiedRequestId === row.request_id ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'" :title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')">
|
||||
<svg v-if="copiedRequestId === row.request_id" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
<Icon v-else name="copy" size="sm" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
defineProps(['data', 'loading'])
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const copiedRequestId = ref<string | 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: 'billing_type', label: t('usage.billingType'), 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: 'request_id', label: t('admin.usage.requestId'), 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 formatDuration = (ms: number | null | undefined): string => {
|
||||
if (ms == null) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
const copyRequestId = async (requestId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(requestId)
|
||||
copiedRequestId.value = requestId
|
||||
appStore.showSuccess(t('admin.usage.requestIdCopied'))
|
||||
setTimeout(() => { copiedRequestId.value = null }, 2000)
|
||||
} catch {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user