Merge branch 'main' into test-dev
This commit is contained in:
@@ -38,7 +38,7 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<button @click="showCreateModal = true" class="btn btn-primary" data-tour="accounts-create-btn">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
@@ -105,65 +105,65 @@
|
||||
|
||||
<template #table>
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div
|
||||
v-if="selectedAccountIds.length > 0"
|
||||
class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
|
||||
</span>
|
||||
<button
|
||||
@click="selectCurrentPageAccounts"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||
</button>
|
||||
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
@click="selectedAccountIds = []"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
<div
|
||||
v-if="selectedAccountIds.length > 0"
|
||||
class="mb-[5px] mt-[10px] px-5 py-1"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
|
||||
</span>
|
||||
<button
|
||||
@click="selectCurrentPageAccounts"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkActions.delete') }}
|
||||
</button>
|
||||
<button @click="showBulkEditModal = true" class="btn btn-primary btn-sm">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||
</button>
|
||||
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
@click="selectedAccountIds = []"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkActions.edit') }}
|
||||
</button>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkActions.delete') }}
|
||||
</button>
|
||||
<button @click="showBulkEditModal = true" class="btn btn-primary btn-sm">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkActions.edit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||
<template #cell-select="{ row }">
|
||||
@@ -373,7 +373,7 @@
|
||||
:proxies="proxies"
|
||||
:groups="groups"
|
||||
@close="showCreateModal = false"
|
||||
@created="loadAccounts"
|
||||
@created="() => { loadAccounts(); if (onboardingStore.isCurrentStep(`[data-tour='account-form-submit']`)) onboardingStore.nextStep(500) }"
|
||||
/>
|
||||
|
||||
<!-- Edit Account Modal -->
|
||||
@@ -495,6 +495,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicIn
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
@@ -524,6 +525,7 @@ import { formatRelativeTime } from '@/utils/format'
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
|
||||
// Table columns
|
||||
const columns = computed<Column[]>(() => {
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
data-tour="groups-create-btn"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
@@ -244,6 +248,7 @@
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.enterGroupName')"
|
||||
data-tour="group-form-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -257,7 +262,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||
<Select v-model="createForm.platform" :options="platformOptions" />
|
||||
<Select
|
||||
v-model="createForm.platform"
|
||||
:options="platformOptions"
|
||||
data-tour="group-form-platform"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
|
||||
</div>
|
||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||
@@ -269,10 +278,11 @@
|
||||
min="0.001"
|
||||
required
|
||||
class="input"
|
||||
data-tour="group-form-multiplier"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
|
||||
</div>
|
||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||
<div v-if="createForm.subscription_type !== 'subscription'" data-tour="group-form-exclusive">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.form.exclusive') }}
|
||||
@@ -390,6 +400,7 @@
|
||||
form="create-group-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
data-tour="group-form-submit"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
@@ -432,7 +443,13 @@
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
|
||||
<input v-model="editForm.name" type="text" required class="input" />
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
data-tour="edit-group-form-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
|
||||
@@ -440,7 +457,12 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||
<Select v-model="editForm.platform" :options="platformOptions" :disabled="true" />
|
||||
<Select
|
||||
v-model="editForm.platform"
|
||||
:options="platformOptions"
|
||||
:disabled="true"
|
||||
data-tour="group-form-platform"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
@@ -452,6 +474,7 @@
|
||||
min="0.001"
|
||||
required
|
||||
class="input"
|
||||
data-tour="group-form-multiplier"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
@@ -580,6 +603,7 @@
|
||||
form="edit-group-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
data-tour="group-form-submit"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
@@ -625,6 +649,7 @@
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
@@ -640,6 +665,7 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('admin.groups.columns.name'), sortable: true },
|
||||
@@ -809,9 +835,14 @@ const handleCreateGroup = async () => {
|
||||
appStore.showSuccess(t('admin.groups.groupCreated'))
|
||||
closeCreateModal()
|
||||
loadGroups()
|
||||
// Only advance tour if active, on submit step, and creation succeeded
|
||||
if (onboardingStore.isCurrentStep('[data-tour="group-form-submit"]')) {
|
||||
onboardingStore.nextStep(500)
|
||||
}
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToCreate'))
|
||||
console.error('Error creating group:', error)
|
||||
// Don't advance tour on error
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -300,8 +300,8 @@
|
||||
<button @click="resetFilters" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button @click="exportToCSV" class="btn btn-primary">
|
||||
{{ t('usage.exportCsv') }}
|
||||
<button @click="exportToExcel" :disabled="exporting" class="btn btn-primary">
|
||||
{{ t('usage.exportExcel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,90 +361,114 @@
|
||||
</template>
|
||||
|
||||
<template #cell-tokens="{ row }">
|
||||
<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">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-emerald-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.input_tokens.toLocaleString()
|
||||
}}</span>
|
||||
<div 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">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-emerald-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
<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">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-violet-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.output_tokens.toLocaleString()
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Output -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-violet-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.output_tokens.toLocaleString()
|
||||
}}</span>
|
||||
<!-- 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">
|
||||
<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>
|
||||
<!-- Cache Write -->
|
||||
<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>
|
||||
<!-- Cache Tokens (Read + Write) -->
|
||||
<!-- Token Detail Tooltip -->
|
||||
<div
|
||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||
class="flex items-center gap-2"
|
||||
class="group relative"
|
||||
@mouseenter="showTokenTooltip($event, row)"
|
||||
@mouseleave="hideTokenTooltip"
|
||||
>
|
||||
<!-- Cache Read -->
|
||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<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"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-sky-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<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"
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<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">
|
||||
<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>
|
||||
@@ -516,9 +540,50 @@
|
||||
</template>
|
||||
|
||||
<template #cell-request_id="{ row }">
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">{{
|
||||
row.request_id || '-'
|
||||
}}</span>
|
||||
<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>
|
||||
<svg
|
||||
v-else
|
||||
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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
@@ -540,6 +605,63 @@
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<ExportProgressDialog
|
||||
:show="exportProgress.show"
|
||||
:progress="exportProgress.progress"
|
||||
:current="exportProgress.current"
|
||||
:total="exportProgress.total"
|
||||
:estimated-time="exportProgress.estimatedTime"
|
||||
@cancel="cancelExport"
|
||||
/>
|
||||
|
||||
<!-- 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 class="mb-2 border-b border-gray-700 pb-1.5">
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</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>
|
||||
<!-- 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
|
||||
@@ -602,10 +724,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { adminUsageAPI } from '@/api/admin/usage'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
@@ -615,6 +741,7 @@ import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import type {
|
||||
@@ -626,12 +753,21 @@ import type {
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||
|
||||
// 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)
|
||||
|
||||
// Request ID copy state
|
||||
const copiedRequestId = ref<string | null>(null)
|
||||
|
||||
// Usage stats from API
|
||||
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
||||
|
||||
@@ -657,6 +793,7 @@ const columns = computed<Column[]>(() => [
|
||||
{ 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 }
|
||||
@@ -669,6 +806,15 @@ const accounts = ref<any[]>([])
|
||||
const groups = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
let exportAbortController: AbortController | null = null
|
||||
const exporting = ref(false)
|
||||
const exportProgress = reactive({
|
||||
show: false,
|
||||
progress: 0,
|
||||
current: 0,
|
||||
total: 0,
|
||||
estimatedTime: ''
|
||||
})
|
||||
|
||||
// User search state
|
||||
const userSearchKeyword = ref('')
|
||||
@@ -868,6 +1014,16 @@ const formatCacheTokens = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const copyRequestId = async (requestId: string) => {
|
||||
const success = await clipboardCopy(requestId, t('admin.usage.requestIdCopied'))
|
||||
if (success) {
|
||||
copiedRequestId.value = requestId
|
||||
setTimeout(() => {
|
||||
copiedRequestId.value = null
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
|
||||
const isAbortError = (error: unknown): boolean => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return true
|
||||
@@ -879,6 +1035,40 @@ const isAbortError = (error: unknown): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
const formatExportTimestamp = (date: Date): string => {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
const formatRemainingTime = (ms: number): string => {
|
||||
const totalSeconds = Math.max(0, Math.round(ms / 1000))
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const parts = []
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0 || hours > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
parts.push(`${seconds}s`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const updateExportProgress = (current: number, total: number, startedAt: number) => {
|
||||
exportProgress.current = current
|
||||
exportProgress.total = total
|
||||
exportProgress.progress = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0
|
||||
if (current > 0 && total > 0) {
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
const remainingMs = Math.max(0, Math.round((elapsedMs / current) * (total - current)))
|
||||
exportProgress.estimatedTime = formatRemainingTime(remainingMs)
|
||||
} else {
|
||||
exportProgress.estimatedTime = ''
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsageLogs = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
@@ -1051,52 +1241,129 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (usageLogs.value.length === 0) {
|
||||
const cancelExport = () => {
|
||||
if (!exporting.value) {
|
||||
return
|
||||
}
|
||||
exportAbortController?.abort()
|
||||
}
|
||||
|
||||
const exportToExcel = async () => {
|
||||
if (pagination.value.total === 0) {
|
||||
appStore.showWarning(t('usage.noDataToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'User',
|
||||
'API Key',
|
||||
'Model',
|
||||
'Type',
|
||||
'Input Tokens',
|
||||
'Output Tokens',
|
||||
'Cache Read Tokens',
|
||||
'Cache Write Tokens',
|
||||
'Total Cost',
|
||||
'Billing Type',
|
||||
'Duration (ms)',
|
||||
'Time'
|
||||
]
|
||||
const rows = usageLogs.value.map((log) => [
|
||||
log.user?.email || '',
|
||||
log.api_key?.name || '',
|
||||
log.model,
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.cache_creation_tokens,
|
||||
log.total_cost.toFixed(6),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.duration_ms,
|
||||
log.created_at
|
||||
])
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n')
|
||||
exporting.value = true
|
||||
exportProgress.show = true
|
||||
exportProgress.progress = 0
|
||||
exportProgress.current = 0
|
||||
exportProgress.total = pagination.value.total
|
||||
exportProgress.estimatedTime = ''
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `admin_usage_${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
const startedAt = Date.now()
|
||||
const controller = new AbortController()
|
||||
exportAbortController = controller
|
||||
|
||||
appStore.showSuccess(t('usage.exportSuccess'))
|
||||
try {
|
||||
const allLogs: UsageLog[] = []
|
||||
const pageSize = 100
|
||||
let page = 1
|
||||
let total = pagination.value.total
|
||||
|
||||
while (true) {
|
||||
const params: AdminUsageQueryParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters.value
|
||||
}
|
||||
const response = await adminUsageAPI.list(params, { signal: controller.signal })
|
||||
if (controller.signal.aborted) {
|
||||
break
|
||||
}
|
||||
if (page === 1) {
|
||||
total = response.total
|
||||
exportProgress.total = total
|
||||
}
|
||||
if (response.items?.length) {
|
||||
allLogs.push(...response.items)
|
||||
}
|
||||
|
||||
updateExportProgress(allLogs.length, total, startedAt)
|
||||
|
||||
if (allLogs.length >= total || response.items.length < pageSize) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
appStore.showInfo(t('usage.exportCancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
if (allLogs.length === 0) {
|
||||
appStore.showWarning(t('usage.noDataToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'User',
|
||||
'API Key',
|
||||
'Model',
|
||||
'Type',
|
||||
'Input Tokens',
|
||||
'Output Tokens',
|
||||
'Cache Read Tokens',
|
||||
'Cache Write Tokens',
|
||||
'Total Cost',
|
||||
'Billing Type',
|
||||
'Duration (ms)',
|
||||
'Time'
|
||||
]
|
||||
const rows = allLogs.map((log) => [
|
||||
log.user?.email || '',
|
||||
log.api_key?.name || '',
|
||||
log.model,
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.cache_creation_tokens,
|
||||
Number(log.total_cost.toFixed(6)),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.duration_ms,
|
||||
log.created_at
|
||||
])
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows])
|
||||
const workbook = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Usage')
|
||||
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
|
||||
const blob = new Blob([excelBuffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
|
||||
saveAs(blob, `admin_usage_${formatExportTimestamp(new Date())}.xlsx`)
|
||||
appStore.showSuccess(t('usage.exportExcelSuccess'))
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || isAbortError(error)) {
|
||||
appStore.showInfo(t('usage.exportCancelled'))
|
||||
return
|
||||
}
|
||||
appStore.showError(t('usage.exportExcelFailed'))
|
||||
console.error('Excel export failed:', error)
|
||||
} finally {
|
||||
if (exportAbortController === controller) {
|
||||
exportAbortController = null
|
||||
}
|
||||
exporting.value = false
|
||||
exportProgress.show = false
|
||||
}
|
||||
}
|
||||
|
||||
// Click outside to close dropdown
|
||||
@@ -1123,6 +1390,22 @@ const hideTooltip = () => {
|
||||
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(() => {
|
||||
loadFilterOptions()
|
||||
loadApiKeys()
|
||||
@@ -1140,5 +1423,8 @@ onUnmounted(() => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
if (exportAbortController) {
|
||||
exportAbortController.abort()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<button @click="showCreateModal = true" class="btn btn-primary" data-tour="keys-create-btn">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
@@ -301,7 +301,7 @@
|
||||
<BaseDialog
|
||||
:show="showCreateModal || showEditModal"
|
||||
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
||||
width="narrow"
|
||||
width="normal"
|
||||
@close="closeModals"
|
||||
>
|
||||
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
|
||||
@@ -313,6 +313,7 @@
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('keys.namePlaceholder')"
|
||||
data-tour="key-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -322,6 +323,7 @@
|
||||
v-model="formData.group_id"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('keys.selectGroup')"
|
||||
data-tour="key-form-group"
|
||||
>
|
||||
<template #selected="{ option }">
|
||||
<GroupBadge
|
||||
@@ -391,7 +393,13 @@
|
||||
<button @click="closeModals" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button form="key-form" type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button
|
||||
form="key-form"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
data-tour="key-form-submit"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
@@ -496,6 +504,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -524,6 +533,7 @@ interface GroupOption {
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
@@ -812,12 +822,17 @@ const handleSubmit = async () => {
|
||||
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
|
||||
await keysAPI.create(formData.value.name, formData.value.group_id, customKey)
|
||||
appStore.showSuccess(t('keys.keyCreatedSuccess'))
|
||||
// Only advance tour if active, on submit step, and creation succeeded
|
||||
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
|
||||
onboardingStore.nextStep(500)
|
||||
}
|
||||
}
|
||||
closeModals()
|
||||
loadApiKeys()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.detail || t('keys.failedToSave')
|
||||
appStore.showError(errorMsg)
|
||||
// Don't advance tour on error
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -885,7 +900,20 @@ const importToCcswitch = (apiKey: string) => {
|
||||
usageAutoInterval: '30'
|
||||
})
|
||||
const deeplink = `ccswitch://v1/import?${params.toString()}`
|
||||
window.open(deeplink, '_self')
|
||||
|
||||
try {
|
||||
window.open(deeplink, '_self')
|
||||
|
||||
// Check if the protocol handler worked by detecting if we're still focused
|
||||
setTimeout(() => {
|
||||
if (document.hasFocus()) {
|
||||
// Still focused means the protocol handler likely failed
|
||||
appStore.showError(t('keys.ccSwitchNotInstalled'))
|
||||
}
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
appStore.showError(t('keys.ccSwitchNotInstalled'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -219,90 +219,114 @@
|
||||
</template>
|
||||
|
||||
<template #cell-tokens="{ row }">
|
||||
<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">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-emerald-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.input_tokens.toLocaleString()
|
||||
}}</span>
|
||||
<div 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">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-emerald-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
<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">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-violet-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.output_tokens.toLocaleString()
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Output -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-violet-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.output_tokens.toLocaleString()
|
||||
}}</span>
|
||||
<!-- 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">
|
||||
<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>
|
||||
<!-- Cache Write -->
|
||||
<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>
|
||||
<!-- Cache Tokens (Read + Write) -->
|
||||
<!-- Token Detail Tooltip -->
|
||||
<div
|
||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||
class="flex items-center gap-2"
|
||||
class="group relative"
|
||||
@mouseenter="showTokenTooltip($event, row)"
|
||||
@mouseleave="hideTokenTooltip"
|
||||
>
|
||||
<!-- Cache Read -->
|
||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<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"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-sky-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<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"
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<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">
|
||||
<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>
|
||||
@@ -392,6 +416,54 @@
|
||||
</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 class="mb-2 border-b border-gray-700 pb-1.5">
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</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>
|
||||
<!-- 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
|
||||
@@ -458,6 +530,11 @@ 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)
|
||||
|
||||
@@ -778,6 +855,22 @@ const hideTooltip = () => {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user