feat(frontend): 为管理端用量页面添加列显示设置
This commit is contained in:
@@ -160,6 +160,7 @@
|
|||||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||||
{{ t('common.reset') }}
|
{{ t('common.reset') }}
|
||||||
</button>
|
</button>
|
||||||
|
<slot name="after-reset" />
|
||||||
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
||||||
{{ t('admin.usage.cleanup.button') }}
|
{{ t('admin.usage.cleanup.button') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<div class="overflow-auto">
|
<div class="overflow-auto">
|
||||||
<DataTable :columns="cols" :data="data" :loading="loading">
|
<DataTable :columns="columns" :data="data" :loading="loading">
|
||||||
<template #cell-user="{ row }">
|
<template #cell-user="{ row }">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-user_agent="{ row }">
|
<template #cell-user_agent="{ row }">
|
||||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] whitespace-normal break-all" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] truncate" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
@@ -276,7 +276,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import type { AdminUsageLog } from '@/types'
|
import type { AdminUsageLog } from '@/types'
|
||||||
|
|
||||||
defineProps(['data', 'loading'])
|
defineProps(['data', 'loading', 'columns'])
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// Tooltip state - cost
|
// Tooltip state - cost
|
||||||
@@ -289,23 +289,6 @@ const tokenTooltipVisible = ref(false)
|
|||||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||||
const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
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: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
|
|
||||||
{ 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 => {
|
const formatCacheTokens = (tokens: number): string => {
|
||||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||||
|
|||||||
@@ -17,8 +17,43 @@
|
|||||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel" />
|
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
|
||||||
<UsageTable :data="usageLogs" :loading="loading" />
|
<template #after-reset>
|
||||||
|
<div class="relative" ref="columnDropdownRef">
|
||||||
|
<button
|
||||||
|
@click="showColumnDropdown = !showColumnDropdown"
|
||||||
|
class="btn btn-secondary px-2 md:px-3"
|
||||||
|
:title="t('admin.users.columnSettings')"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showColumnDropdown"
|
||||||
|
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="col in toggleableColumns"
|
||||||
|
:key="col.key"
|
||||||
|
@click="toggleColumn(col.key)"
|
||||||
|
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
<span>{{ col.label }}</span>
|
||||||
|
<Icon
|
||||||
|
v-if="isColumnVisible(col.key)"
|
||||||
|
name="check"
|
||||||
|
size="sm"
|
||||||
|
class="text-primary-500"
|
||||||
|
:stroke-width="2"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UsageFilters>
|
||||||
|
<UsageTable :data="usageLogs" :loading="loading" :columns="visibleColumns" />
|
||||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
@@ -43,6 +78,7 @@ import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; impo
|
|||||||
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
||||||
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
||||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -141,6 +177,77 @@ const exportToExcel = async () => {
|
|||||||
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
|
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadLogs(); loadStats(); loadChartData() })
|
// Column visibility
|
||||||
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort() })
|
const ALWAYS_VISIBLE = ['user', 'created_at']
|
||||||
|
const DEFAULT_HIDDEN_COLUMNS = ['reasoning_effort', 'user_agent']
|
||||||
|
const HIDDEN_COLUMNS_KEY = 'usage-hidden-columns'
|
||||||
|
|
||||||
|
const allColumns = 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: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
|
||||||
|
{ 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 hiddenColumns = reactive<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const toggleableColumns = computed(() =>
|
||||||
|
allColumns.value.filter(col => !ALWAYS_VISIBLE.includes(col.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleColumns = computed(() =>
|
||||||
|
allColumns.value.filter(col =>
|
||||||
|
ALWAYS_VISIBLE.includes(col.key) || !hiddenColumns.has(col.key)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||||
|
|
||||||
|
const toggleColumn = (key: string) => {
|
||||||
|
if (hiddenColumns.has(key)) {
|
||||||
|
hiddenColumns.delete(key)
|
||||||
|
} else {
|
||||||
|
hiddenColumns.add(key)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save columns:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSavedColumns = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||||
|
if (saved) {
|
||||||
|
(JSON.parse(saved) as string[]).forEach(key => hiddenColumns.add(key))
|
||||||
|
} else {
|
||||||
|
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showColumnDropdown = ref(false)
|
||||||
|
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const handleColumnClickOutside = (event: MouseEvent) => {
|
||||||
|
if (columnDropdownRef.value && !columnDropdownRef.value.contains(event.target as HTMLElement)) {
|
||||||
|
showColumnDropdown.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadLogs(); loadStats(); loadChartData(); loadSavedColumns(); document.addEventListener('click', handleColumnClickOutside) })
|
||||||
|
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user