mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 00:27:30 +00:00
891 lines
32 KiB
Vue
891 lines
32 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<div class="space-y-6">
|
|
<UsageStatsCards :stats="usageStats" :show-account-cost="false" :strike-standard-cost="true" />
|
|
|
|
<div class="space-y-4">
|
|
<div class="card p-4">
|
|
<div class="flex flex-wrap items-center gap-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.timeRange') }}:</span>
|
|
<DateRangePicker
|
|
v-model:start-date="startDate"
|
|
v-model:end-date="endDate"
|
|
@change="onDateRangeChange"
|
|
/>
|
|
</div>
|
|
<div class="ml-auto flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.granularity') }}:</span>
|
|
<div class="w-28">
|
|
<Select v-model="granularity" :options="granularityOptions" @change="loadChartData" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
<ModelDistributionChart
|
|
v-model:metric="modelDistributionMetric"
|
|
:model-stats="requestedModelStats"
|
|
:loading="modelStatsLoading"
|
|
:show-source-toggle="false"
|
|
:show-metric-toggle="true"
|
|
:enable-breakdown="false"
|
|
:show-account-cost="false"
|
|
:start-date="startDate"
|
|
:end-date="endDate"
|
|
/>
|
|
<GroupDistributionChart
|
|
v-model:metric="groupDistributionMetric"
|
|
:group-stats="groupStats"
|
|
:loading="chartsLoading"
|
|
:show-metric-toggle="true"
|
|
:enable-breakdown="false"
|
|
:show-account-cost="false"
|
|
:start-date="startDate"
|
|
:end-date="endDate"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
<EndpointDistributionChart
|
|
v-model:source="endpointDistributionSource"
|
|
v-model:metric="endpointDistributionMetric"
|
|
:endpoint-stats="inboundEndpointStats"
|
|
:upstream-endpoint-stats="upstreamEndpointStats"
|
|
:endpoint-path-stats="endpointPathStats"
|
|
:loading="endpointStatsLoading"
|
|
:show-source-toggle="false"
|
|
:show-metric-toggle="true"
|
|
:enable-breakdown="false"
|
|
:title="t('usage.endpointDistribution')"
|
|
:start-date="startDate"
|
|
:end-date="endDate"
|
|
/>
|
|
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card p-6">
|
|
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
<div v-if="activeTab === 'errors'" class="flex flex-1 flex-wrap items-end gap-4">
|
|
<div class="w-full sm:w-auto sm:min-w-[220px]">
|
|
<label class="input-label">{{ t('usage.errors.keyName') }}</label>
|
|
<Select v-model="errorFilter.api_key_id" :options="errorKeyOptions" @change="applyErrorFilters" />
|
|
</div>
|
|
<div class="w-full sm:w-auto sm:min-w-[220px]">
|
|
<label class="input-label">{{ t('usage.errors.model') }}</label>
|
|
<Select
|
|
v-model="errorFilter.model"
|
|
:options="errorModelOptions"
|
|
searchable
|
|
creatable
|
|
clearable
|
|
:placeholder="t('usage.errors.modelPlaceholder')"
|
|
@change="applyErrorFilters"
|
|
/>
|
|
</div>
|
|
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
|
<label class="input-label">{{ t('usage.errors.category') }}</label>
|
|
<Select v-model="errorFilter.category" :options="errorCategoryOptions" @change="applyErrorFilters" />
|
|
</div>
|
|
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
|
<label class="input-label">{{ t('usage.errors.status') }}</label>
|
|
<Select v-model="errorFilter.status_code" :options="errorStatusOptions" @change="applyErrorFilters" />
|
|
</div>
|
|
</div>
|
|
<div v-else class="flex flex-1 flex-wrap items-end gap-4">
|
|
<div class="w-full sm:w-auto sm:min-w-[220px]">
|
|
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
|
|
<Select v-model="filters.api_key_id" :options="apiKeyOptions" @change="applyFilters" />
|
|
</div>
|
|
<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="applyFilters" />
|
|
</div>
|
|
<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="applyFilters" />
|
|
</div>
|
|
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
|
<label class="input-label">{{ t('usage.type') }}</label>
|
|
<Select v-model="filters.request_type" :options="requestTypeOptions" @change="applyFilters" />
|
|
</div>
|
|
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
|
<label class="input-label">{{ t('admin.usage.billingType') }}</label>
|
|
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="applyFilters" />
|
|
</div>
|
|
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
|
<label class="input-label">{{ t('admin.usage.billingMode') }}</label>
|
|
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="applyFilters" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
|
<button type="button" @click="refreshData" :disabled="activeTab === 'errors' ? errorLoading : loading" class="btn btn-secondary">
|
|
{{ t('common.refresh') }}
|
|
</button>
|
|
<button type="button" @click="resetFilters" class="btn btn-secondary">
|
|
{{ t('common.reset') }}
|
|
</button>
|
|
<div class="relative" ref="columnDropdownRef">
|
|
<button
|
|
type="button"
|
|
@click="showColumnDropdown = !showColumnDropdown"
|
|
class="btn btn-secondary px-2 md:px-3"
|
|
:title="t('admin.users.columnSettings')"
|
|
>
|
|
<Icon name="grid" size="sm" />
|
|
<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 currentToggleableColumns"
|
|
:key="col.key"
|
|
type="button"
|
|
@click="toggleCurrentColumn(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="isCurrentColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button v-if="activeTab !== 'errors'" type="button" @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
|
|
{{ exporting ? t('usage.exporting') : t('usage.exportCsv') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="errorViewEnabled" class="flex gap-2 border-b border-gray-200 dark:border-dark-700">
|
|
<button class="tab" :class="{ 'tab-active': activeTab === 'usage' }" @click="activeTab = 'usage'">
|
|
{{ t('usage.tabs.usage') }}
|
|
</button>
|
|
<button class="tab" :class="{ 'tab-active': activeTab === 'errors' }" @click="switchToErrors">
|
|
{{ t('usage.tabs.errors') }}
|
|
</button>
|
|
</div>
|
|
|
|
<template v-if="activeTab === 'usage'">
|
|
<UsageTable
|
|
:data="usageLogs"
|
|
:loading="loading"
|
|
:columns="visibleColumns"
|
|
:server-side-sort="true"
|
|
:show-account-billing="false"
|
|
:show-upstream-endpoint="false"
|
|
default-sort-key="created_at"
|
|
default-sort-order="desc"
|
|
@sort="handleSort"
|
|
@ipGeoBatchFailed="handleIpGeoBatchFailed"
|
|
/>
|
|
|
|
<Pagination
|
|
v-if="pagination.total > 0"
|
|
:page="pagination.page"
|
|
:total="pagination.total"
|
|
:page-size="pagination.page_size"
|
|
@update:page="handlePageChange"
|
|
@update:pageSize="handlePageSizeChange"
|
|
/>
|
|
</template>
|
|
|
|
<UserErrorRequestsTable
|
|
v-else-if="errorViewEnabled"
|
|
:rows="errorRows"
|
|
:total="errorTotal"
|
|
:loading="errorLoading"
|
|
:page="errorPage"
|
|
:page-size="errorPageSize"
|
|
:visible-column-keys="errVisibleColumnKeys"
|
|
@sort="onErrorSort"
|
|
@update:page="onErrorPage"
|
|
@update:pageSize="onErrorPageSize"
|
|
@ipGeoBatchFailed="handleIpGeoBatchFailed"
|
|
/>
|
|
</div>
|
|
</AppLayout>
|
|
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAppStore } from '@/stores/app'
|
|
import { keysAPI, usageAPI, userGroupsAPI } from '@/api'
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
|
import Pagination from '@/components/common/Pagination.vue'
|
|
import Select, { type SelectOption } from '@/components/common/Select.vue'
|
|
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
|
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'
|
|
import UsageTable from '@/components/admin/usage/UsageTable.vue'
|
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
|
import GroupDistributionChart from '@/components/charts/GroupDistributionChart.vue'
|
|
import EndpointDistributionChart from '@/components/charts/EndpointDistributionChart.vue'
|
|
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import UserErrorRequestsTable from '@/components/user/UserErrorRequestsTable.vue'
|
|
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
|
import { formatReasoningEffort } from '@/utils/format'
|
|
import { BILLING_MODE_IMAGE, getBillingModeLabel } from '@/utils/billingMode'
|
|
import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType'
|
|
import type {
|
|
ApiKey,
|
|
EndpointStat,
|
|
Group,
|
|
GroupStat,
|
|
ModelStat,
|
|
TrendDataPoint,
|
|
UsageLog,
|
|
UsageQueryParams,
|
|
UsageStatsResponse,
|
|
UserErrorRequest,
|
|
} from '@/types'
|
|
import type { Column } from '@/components/common/types'
|
|
import { COMMON_ERROR_STATUS_CODES } from '@/utils/errorBadges'
|
|
|
|
const { t } = useI18n()
|
|
const appStore = useAppStore()
|
|
|
|
type DistributionMetric = 'tokens' | 'actual_cost'
|
|
type EndpointSource = 'inbound' | 'upstream' | 'path'
|
|
|
|
const usageStats = ref<UsageStatsResponse | null>(null)
|
|
const usageLogs = ref<UsageLog[]>([])
|
|
const trendData = ref<TrendDataPoint[]>([])
|
|
const requestedModelStats = ref<ModelStat[]>([])
|
|
const groupStats = ref<GroupStat[]>([])
|
|
const inboundEndpointStats = ref<EndpointStat[]>([])
|
|
const upstreamEndpointStats = ref<EndpointStat[]>([])
|
|
const endpointPathStats = ref<EndpointStat[]>([])
|
|
|
|
const loading = ref(false)
|
|
const chartsLoading = ref(false)
|
|
const modelStatsLoading = ref(false)
|
|
const endpointStatsLoading = ref(false)
|
|
const exporting = ref(false)
|
|
const errorRows = ref<UserErrorRequest[]>([])
|
|
const errorLoading = ref(false)
|
|
const errorPage = ref(1)
|
|
const errorPageSize = ref(20)
|
|
const errorSortBy = ref('created_at')
|
|
const errorSortOrder = ref<'asc' | 'desc'>('desc')
|
|
const errorTotal = ref(0)
|
|
const errorFilter = ref<{ model: string | null; category: string; api_key_id: number | null; status_code: number | null }>({
|
|
model: '',
|
|
category: '',
|
|
api_key_id: null,
|
|
status_code: null,
|
|
})
|
|
|
|
const errorKeyOptions = computed<SelectOption[]>(() => [
|
|
{ value: null, label: t('usage.errors.allKeys') },
|
|
...apiKeys.value.map((k) => ({ value: k.id, label: k.name })),
|
|
])
|
|
|
|
// 模型候选取自当前已加载错误中出现过的模型;creatable 允许输入任意片段做后端模糊。
|
|
const errorModelOptions = computed<SelectOption[]>(() => {
|
|
const seen = new Set<string>()
|
|
const opts: SelectOption[] = []
|
|
for (const r of errorRows.value) {
|
|
if (r.model && !seen.has(r.model)) {
|
|
seen.add(r.model)
|
|
opts.push({ value: r.model, label: r.model })
|
|
}
|
|
}
|
|
return opts
|
|
})
|
|
|
|
const errorCategoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal', 'cyber']
|
|
|
|
const errorCategoryOptions = computed<SelectOption[]>(() => [
|
|
{ value: '', label: t('usage.errors.allCategories') },
|
|
...errorCategoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
|
|
])
|
|
|
|
// 状态码候选用固定常用列表(与管理端 UsageFilters 共用常量),不受当前页数据限制:
|
|
// 后端 status_code 过滤对全量生效,若只列当前页出现过的码,用户就选不到仅在后续页的码。
|
|
const errorStatusOptions = computed<SelectOption[]>(() => [
|
|
{ value: null, label: t('usage.errors.allStatuses') },
|
|
...COMMON_ERROR_STATUS_CODES.map((c) => ({ value: c, label: String(c) })),
|
|
])
|
|
|
|
const applyErrorFilters = () => {
|
|
errorPage.value = 1
|
|
void loadErrors()
|
|
}
|
|
|
|
let abortController: AbortController | null = null
|
|
let chartReqSeq = 0
|
|
let statsReqSeq = 0
|
|
let modelStatsReqSeq = 0
|
|
|
|
const formatLocalDate = (date: Date): string =>
|
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
|
|
const getLast24HoursRangeDates = () => {
|
|
const end = new Date()
|
|
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
|
|
return { start: formatLocalDate(start), end: formatLocalDate(end) }
|
|
}
|
|
|
|
const getGranularityForRange = (start: string, end: string): 'day' | 'hour' => {
|
|
const startTime = new Date(`${start}T00:00:00`).getTime()
|
|
const endTime = new Date(`${end}T00:00:00`).getTime()
|
|
return Math.ceil((endTime - startTime) / (1000 * 60 * 60 * 24)) <= 1 ? 'hour' : 'day'
|
|
}
|
|
|
|
const defaultRange = getLast24HoursRangeDates()
|
|
const startDate = ref(defaultRange.start)
|
|
const endDate = ref(defaultRange.end)
|
|
const granularity = ref<'day' | 'hour'>(getGranularityForRange(startDate.value, endDate.value))
|
|
|
|
const modelDistributionMetric = ref<DistributionMetric>('tokens')
|
|
const groupDistributionMetric = ref<DistributionMetric>('tokens')
|
|
const endpointDistributionMetric = ref<DistributionMetric>('tokens')
|
|
const endpointDistributionSource = ref<EndpointSource>('inbound')
|
|
const activeTab = ref<'usage' | 'errors'>('usage')
|
|
const errorViewEnabled = computed(() => appStore.cachedPublicSettings?.allow_user_view_error_requests ?? false)
|
|
|
|
const filters = ref<UsageQueryParams>({
|
|
start_date: startDate.value,
|
|
end_date: endDate.value,
|
|
request_type: undefined,
|
|
billing_type: null,
|
|
billing_mode: null,
|
|
})
|
|
|
|
const pagination = reactive({
|
|
page: 1,
|
|
page_size: getPersistedPageSize(),
|
|
total: 0,
|
|
})
|
|
const sortState = reactive({
|
|
sort_by: 'created_at',
|
|
sort_order: 'desc' as 'asc' | 'desc',
|
|
})
|
|
|
|
const granularityOptions = computed<SelectOption[]>(() => [
|
|
{ value: 'day', label: t('admin.dashboard.day') },
|
|
{ value: 'hour', label: t('admin.dashboard.hour') },
|
|
])
|
|
const requestTypeOptions = computed<SelectOption[]>(() => [
|
|
{ value: null, label: t('admin.usage.allTypes') },
|
|
{ value: 'ws_v2', label: t('usage.ws') },
|
|
{ value: 'stream', label: t('usage.stream') },
|
|
{ value: 'sync', label: t('usage.sync') },
|
|
])
|
|
const billingTypeOptions = computed<SelectOption[]>(() => [
|
|
{ value: null, label: t('admin.usage.allBillingTypes') },
|
|
{ value: 0, label: t('admin.usage.billingTypeBalance') },
|
|
{ value: 1, label: t('admin.usage.billingTypeSubscription') },
|
|
])
|
|
const billingModeOptions = computed<SelectOption[]>(() => [
|
|
{ value: null, label: t('admin.usage.allBillingModes') },
|
|
{ value: 'token', label: t('admin.usage.billingModeToken') },
|
|
{ value: 'per_request', label: t('admin.usage.billingModePerRequest') },
|
|
{ value: 'image', label: t('admin.usage.billingModeImage') },
|
|
])
|
|
|
|
const apiKeys = ref<ApiKey[]>([])
|
|
const groups = ref<Group[]>([])
|
|
const modelOptionValues = ref<string[]>([])
|
|
|
|
const apiKeyOptions = computed<SelectOption[]>(() => [
|
|
{ value: null, label: t('usage.allApiKeys') },
|
|
...apiKeys.value.map((key) => ({ value: key.id, label: key.name })),
|
|
])
|
|
const groupOptions = computed<SelectOption[]>(() => [
|
|
{ value: null, label: t('admin.usage.allGroups') },
|
|
...groups.value.map((group) => ({ value: group.id, label: group.name })),
|
|
])
|
|
const modelOptions = computed<SelectOption[]>(() => [
|
|
{ value: null, label: t('admin.usage.allModels') },
|
|
...modelOptionValues.value.map((model) => ({ value: model, label: model })),
|
|
])
|
|
|
|
const normalizedFilters = computed<UsageQueryParams>(() => {
|
|
const requestType = filters.value.request_type
|
|
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
|
|
return {
|
|
...filters.value,
|
|
start_date: startDate.value,
|
|
end_date: endDate.value,
|
|
stream: legacyStream === null ? undefined : legacyStream,
|
|
}
|
|
})
|
|
|
|
const buildUsageListParams = (page: number, pageSize: number): UsageQueryParams => ({
|
|
page,
|
|
page_size: pageSize,
|
|
...normalizedFilters.value,
|
|
sort_by: sortState.sort_by,
|
|
sort_order: sortState.sort_order,
|
|
})
|
|
|
|
const loadLogs = async () => {
|
|
abortController?.abort()
|
|
const controller = new AbortController()
|
|
abortController = controller
|
|
loading.value = true
|
|
try {
|
|
const res = await usageAPI.query(buildUsageListParams(pagination.page, pagination.page_size), {
|
|
signal: controller.signal,
|
|
})
|
|
if (!controller.signal.aborted) {
|
|
usageLogs.value = res.items
|
|
pagination.total = res.total
|
|
}
|
|
} catch (error: any) {
|
|
if (error?.name !== 'AbortError' && error?.code !== 'ERR_CANCELED') {
|
|
appStore.showError(t('usage.failedToLoad'))
|
|
}
|
|
} finally {
|
|
if (abortController === controller) loading.value = false
|
|
}
|
|
}
|
|
|
|
const loadStats = async () => {
|
|
const seq = ++statsReqSeq
|
|
endpointStatsLoading.value = true
|
|
try {
|
|
const stats = await usageAPI.getStats(normalizedFilters.value)
|
|
if (seq !== statsReqSeq) return
|
|
usageStats.value = stats
|
|
inboundEndpointStats.value = stats.endpoints || []
|
|
upstreamEndpointStats.value = []
|
|
endpointPathStats.value = []
|
|
} catch (error) {
|
|
if (seq !== statsReqSeq) return
|
|
console.error('Failed to load usage stats:', error)
|
|
inboundEndpointStats.value = []
|
|
upstreamEndpointStats.value = []
|
|
endpointPathStats.value = []
|
|
} finally {
|
|
if (seq === statsReqSeq) endpointStatsLoading.value = false
|
|
}
|
|
}
|
|
|
|
const loadModelStats = async () => {
|
|
const seq = ++modelStatsReqSeq
|
|
modelStatsLoading.value = true
|
|
try {
|
|
const response = await usageAPI.getDashboardModels({
|
|
...normalizedFilters.value,
|
|
model_source: 'requested',
|
|
})
|
|
if (seq !== modelStatsReqSeq) return
|
|
requestedModelStats.value = response.models || []
|
|
refreshModelOptions(response.models || [])
|
|
} catch (error) {
|
|
if (seq !== modelStatsReqSeq) return
|
|
console.error('Failed to load model stats:', error)
|
|
requestedModelStats.value = []
|
|
} finally {
|
|
if (seq === modelStatsReqSeq) modelStatsLoading.value = false
|
|
}
|
|
}
|
|
|
|
const loadChartData = async () => {
|
|
const seq = ++chartReqSeq
|
|
chartsLoading.value = true
|
|
try {
|
|
const snapshot = await usageAPI.getDashboardSnapshotV2({
|
|
...normalizedFilters.value,
|
|
granularity: granularity.value,
|
|
include_trend: true,
|
|
include_model_stats: false,
|
|
include_group_stats: true,
|
|
})
|
|
if (seq !== chartReqSeq) return
|
|
trendData.value = snapshot.trend || []
|
|
groupStats.value = snapshot.groups || []
|
|
} catch (error) {
|
|
if (seq !== chartReqSeq) return
|
|
console.error('Failed to load chart data:', error)
|
|
trendData.value = []
|
|
groupStats.value = []
|
|
} finally {
|
|
if (seq === chartReqSeq) chartsLoading.value = false
|
|
}
|
|
}
|
|
|
|
const refreshModelOptions = (models: ModelStat[]) => {
|
|
const current = filters.value.model
|
|
const set = new Set(modelOptionValues.value)
|
|
models.forEach((item) => {
|
|
if (item.model) set.add(item.model)
|
|
})
|
|
if (current) set.add(current)
|
|
modelOptionValues.value = Array.from(set).sort()
|
|
}
|
|
|
|
const applyFilters = () => {
|
|
pagination.page = 1
|
|
void loadLogs()
|
|
void loadStats()
|
|
void loadModelStats()
|
|
void loadChartData()
|
|
resetErrorRows()
|
|
}
|
|
|
|
const refreshData = () => {
|
|
void loadLogs()
|
|
void loadStats()
|
|
void loadModelStats()
|
|
void loadChartData()
|
|
if (activeTab.value === 'errors') void loadErrors()
|
|
}
|
|
|
|
const resetFilters = () => {
|
|
const range = getLast24HoursRangeDates()
|
|
startDate.value = range.start
|
|
endDate.value = range.end
|
|
filters.value = {
|
|
start_date: range.start,
|
|
end_date: range.end,
|
|
request_type: undefined,
|
|
billing_type: null,
|
|
billing_mode: null,
|
|
}
|
|
granularity.value = getGranularityForRange(range.start, range.end)
|
|
applyFilters()
|
|
if (activeTab.value === 'errors') {
|
|
errorFilter.value = { model: '', category: '', api_key_id: null, status_code: null }
|
|
applyErrorFilters()
|
|
}
|
|
}
|
|
|
|
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
|
|
startDate.value = range.startDate
|
|
endDate.value = range.endDate
|
|
filters.value.start_date = range.startDate
|
|
filters.value.end_date = range.endDate
|
|
granularity.value = getGranularityForRange(range.startDate, range.endDate)
|
|
applyFilters()
|
|
}
|
|
|
|
const handlePageChange = (page: number) => {
|
|
pagination.page = page
|
|
void loadLogs()
|
|
}
|
|
|
|
const handlePageSizeChange = (pageSize: number) => {
|
|
pagination.page_size = pageSize
|
|
pagination.page = 1
|
|
void loadLogs()
|
|
}
|
|
|
|
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
|
sortState.sort_by = key
|
|
sortState.sort_order = order
|
|
pagination.page = 1
|
|
void loadLogs()
|
|
}
|
|
|
|
const handleIpGeoBatchFailed = () => {
|
|
appStore.showError(t('usage.ipGeo.batchFailed'))
|
|
}
|
|
|
|
const getRequestTypeExportText = (log: UsageLog): string => {
|
|
const requestType = resolveUsageRequestType(log)
|
|
if (requestType === 'cyber') return 'Cyber'
|
|
if (requestType === 'ws_v2') return 'WS'
|
|
if (requestType === 'stream') return 'Stream'
|
|
if (requestType === 'sync') return 'Sync'
|
|
return 'Unknown'
|
|
}
|
|
|
|
const getDisplayBillingMode = (
|
|
row: Pick<UsageLog, 'billing_mode' | 'image_count'> | null | undefined
|
|
): string | null | undefined => {
|
|
if ((row?.image_count ?? 0) > 0) return BILLING_MODE_IMAGE
|
|
return row?.billing_mode
|
|
}
|
|
|
|
const escapeCSVValue = (value: unknown): string => {
|
|
if (value == null) return ''
|
|
const str = String(value)
|
|
const escaped = str.replace(/"/g, '""')
|
|
if (/^[=+\-@\t\r]/.test(str)) return `"\'${escaped}"`
|
|
if (/[,"\n\r]/.test(str)) return `"${escaped}"`
|
|
return str
|
|
}
|
|
|
|
const exportToCSV = async () => {
|
|
if (pagination.total === 0) {
|
|
appStore.showWarning(t('usage.noDataToExport'))
|
|
return
|
|
}
|
|
exporting.value = true
|
|
appStore.showInfo(t('usage.preparingExport'))
|
|
try {
|
|
const allLogs: UsageLog[] = []
|
|
const pageSize = 100
|
|
const totalPages = Math.ceil(pagination.total / pageSize)
|
|
for (let page = 1; page <= totalPages; page++) {
|
|
const response = await usageAPI.query(buildUsageListParams(page, pageSize))
|
|
allLogs.push(...response.items)
|
|
}
|
|
if (allLogs.length === 0) {
|
|
appStore.showWarning(t('usage.noDataToExport'))
|
|
return
|
|
}
|
|
const headers = [
|
|
'Time',
|
|
'API Key Name',
|
|
'Model',
|
|
'Reasoning Effort',
|
|
'Inbound Endpoint',
|
|
'IP Address',
|
|
'Type',
|
|
'Billing Mode',
|
|
'Input Tokens',
|
|
'Output Tokens',
|
|
'Cache Read Tokens',
|
|
'Cache Creation Tokens',
|
|
'Rate Multiplier',
|
|
'Billed Cost',
|
|
'Original Cost',
|
|
'First Token (ms)',
|
|
'Duration (ms)',
|
|
]
|
|
const rows = allLogs.map((log) => [
|
|
log.created_at,
|
|
log.api_key?.name || '',
|
|
log.model,
|
|
formatReasoningEffort(log.reasoning_effort),
|
|
log.inbound_endpoint || '',
|
|
log.ip_address || '',
|
|
getRequestTypeExportText(log),
|
|
getBillingModeLabel(getDisplayBillingMode(log), t),
|
|
log.input_tokens,
|
|
log.output_tokens,
|
|
log.cache_read_tokens,
|
|
log.cache_creation_tokens,
|
|
log.rate_multiplier,
|
|
log.actual_cost.toFixed(8),
|
|
log.total_cost.toFixed(8),
|
|
log.first_token_ms ?? '',
|
|
log.duration_ms ?? '',
|
|
].map(escapeCSVValue))
|
|
const csvContent = [
|
|
headers.map(escapeCSVValue).join(','),
|
|
...rows.map((row) => row.join(',')),
|
|
].join('\n')
|
|
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
|
const url = window.URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `usage_${startDate.value}_to_${endDate.value}.csv`
|
|
link.click()
|
|
window.URL.revokeObjectURL(url)
|
|
appStore.showSuccess(t('usage.exportSuccess'))
|
|
} catch (error) {
|
|
console.error('CSV Export failed:', error)
|
|
appStore.showError(t('usage.exportFailed'))
|
|
} finally {
|
|
exporting.value = false
|
|
}
|
|
}
|
|
|
|
const ALWAYS_VISIBLE = ['created_at']
|
|
const DEFAULT_HIDDEN_COLUMNS = ['user_agent']
|
|
const HIDDEN_COLUMNS_KEY = 'user-usage-hidden-columns'
|
|
|
|
const allColumns = computed<Column[]>(() => [
|
|
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
|
{ key: 'model', label: t('usage.model'), sortable: true },
|
|
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
|
|
{ key: 'endpoint', label: t('usage.endpoint'), sortable: false },
|
|
{ key: 'ip_address', label: 'IP', sortable: false },
|
|
{ key: 'group', label: t('admin.usage.group'), sortable: false },
|
|
{ key: 'stream', label: t('usage.type'), sortable: false },
|
|
{ key: 'billing_mode', label: t('admin.usage.billingMode'), 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 },
|
|
])
|
|
|
|
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)
|
|
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
|
}
|
|
const loadSavedColumns = () => {
|
|
try {
|
|
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
|
const values = saved ? JSON.parse(saved) as string[] : DEFAULT_HIDDEN_COLUMNS
|
|
values.forEach((key) => hiddenColumns.add(key))
|
|
} catch {
|
|
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
|
|
}
|
|
}
|
|
|
|
// 错误请求 tab 独立列设置(机制同用量列设置,存储互不影响)
|
|
const ERR_ALWAYS_VISIBLE = ['status', 'created_at']
|
|
const ERR_DEFAULT_HIDDEN_COLUMNS = ['user_agent']
|
|
const ERR_HIDDEN_COLUMNS_KEY = 'user-usage-error-hidden-columns'
|
|
|
|
// key 须与 UserErrorRequestsTable 的 allColumns 一致
|
|
const errAllColumns = computed<Column[]>(() => [
|
|
{ key: 'key_name', label: t('usage.errors.keyName') },
|
|
{ key: 'model', label: t('usage.errors.model') },
|
|
{ key: 'endpoint', label: t('usage.errors.endpoint') },
|
|
{ key: 'client_ip', label: 'IP' },
|
|
{ key: 'group', label: t('admin.usage.group') },
|
|
{ key: 'type', label: t('usage.type') },
|
|
{ key: 'platform', label: t('usage.errors.platform') },
|
|
{ key: 'category', label: t('usage.errors.category') },
|
|
{ key: 'status', label: t('usage.errors.status') },
|
|
{ key: 'message', label: t('usage.errors.message') },
|
|
{ key: 'created_at', label: t('usage.errors.time') },
|
|
{ key: 'user_agent', label: t('usage.userAgent') },
|
|
])
|
|
|
|
const errHiddenColumns = reactive<Set<string>>(new Set())
|
|
const errToggleableColumns = computed(() =>
|
|
errAllColumns.value.filter((col) => !ERR_ALWAYS_VISIBLE.includes(col.key))
|
|
)
|
|
const errVisibleColumnKeys = computed(() =>
|
|
errAllColumns.value
|
|
.filter((col) => ERR_ALWAYS_VISIBLE.includes(col.key) || !errHiddenColumns.has(col.key))
|
|
.map((col) => col.key)
|
|
)
|
|
const isErrColumnVisible = (key: string) => !errHiddenColumns.has(key)
|
|
const toggleErrColumn = (key: string) => {
|
|
if (errHiddenColumns.has(key)) errHiddenColumns.delete(key)
|
|
else errHiddenColumns.add(key)
|
|
localStorage.setItem(ERR_HIDDEN_COLUMNS_KEY, JSON.stringify([...errHiddenColumns]))
|
|
}
|
|
const loadSavedErrColumns = () => {
|
|
try {
|
|
const saved = localStorage.getItem(ERR_HIDDEN_COLUMNS_KEY)
|
|
const values = saved ? (JSON.parse(saved) as string[]) : ERR_DEFAULT_HIDDEN_COLUMNS
|
|
values.forEach((key) => errHiddenColumns.add(key))
|
|
} catch {
|
|
ERR_DEFAULT_HIDDEN_COLUMNS.forEach((key) => errHiddenColumns.add(key))
|
|
}
|
|
}
|
|
|
|
// 列设置下拉按当前 tab 分发
|
|
const currentToggleableColumns = computed(() =>
|
|
activeTab.value === 'errors' ? errToggleableColumns.value : toggleableColumns.value
|
|
)
|
|
const isCurrentColumnVisible = (key: string) =>
|
|
activeTab.value === 'errors' ? isErrColumnVisible(key) : isColumnVisible(key)
|
|
const toggleCurrentColumn = (key: string) => {
|
|
if (activeTab.value === 'errors') toggleErrColumn(key)
|
|
else toggleColumn(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
|
|
}
|
|
}
|
|
|
|
const loadFilterOptions = async () => {
|
|
try {
|
|
const [keys, availableGroups] = await Promise.all([
|
|
keysAPI.list(1, 100),
|
|
userGroupsAPI.getAvailable(),
|
|
])
|
|
apiKeys.value = keys.items
|
|
groups.value = availableGroups
|
|
} catch (error) {
|
|
console.error('Failed to load usage filter options:', error)
|
|
}
|
|
}
|
|
|
|
const resetErrorRows = () => {
|
|
errorPage.value = 1
|
|
if (activeTab.value === 'errors') {
|
|
void loadErrors()
|
|
} else {
|
|
errorRows.value = []
|
|
errorTotal.value = 0
|
|
}
|
|
}
|
|
|
|
const loadErrors = async () => {
|
|
errorLoading.value = true
|
|
try {
|
|
const resp = await usageAPI.listMyErrorRequests({
|
|
page: errorPage.value,
|
|
page_size: errorPageSize.value,
|
|
start_date: startDate.value,
|
|
end_date: endDate.value,
|
|
model: (errorFilter.value.model ?? '').trim() || undefined,
|
|
category: errorFilter.value.category || undefined,
|
|
api_key_id: errorFilter.value.api_key_id ?? undefined,
|
|
status_code: errorFilter.value.status_code ?? undefined,
|
|
sort_by: errorSortBy.value,
|
|
sort_order: errorSortOrder.value,
|
|
})
|
|
errorRows.value = resp.items
|
|
errorTotal.value = resp.total
|
|
} catch (error) {
|
|
console.error('[UsageView] loadErrors failed:', error)
|
|
appStore.showError(t('usage.errors.failedToLoad'))
|
|
} finally {
|
|
errorLoading.value = false
|
|
}
|
|
}
|
|
|
|
const onErrorSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
|
|
errorSortBy.value = sortBy
|
|
errorSortOrder.value = sortOrder
|
|
errorPage.value = 1
|
|
void loadErrors()
|
|
}
|
|
|
|
const onErrorPage = (page: number) => {
|
|
errorPage.value = page
|
|
void loadErrors()
|
|
}
|
|
|
|
const onErrorPageSize = (pageSize: number) => {
|
|
errorPageSize.value = pageSize
|
|
errorPage.value = 1
|
|
void loadErrors()
|
|
}
|
|
|
|
const switchToErrors = () => {
|
|
activeTab.value = 'errors'
|
|
if (errorRows.value.length === 0) void loadErrors()
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadSavedColumns()
|
|
loadSavedErrColumns()
|
|
document.addEventListener('click', handleColumnClickOutside)
|
|
void loadFilterOptions()
|
|
refreshData()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
abortController?.abort()
|
|
document.removeEventListener('click', handleColumnClickOutside)
|
|
})
|
|
|
|
watch(endpointDistributionSource, () => {
|
|
// Endpoint source switching is handled by the chart component using already loaded stats.
|
|
})
|
|
</script>
|