mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
feat(usage): 错误请求对齐用量明细(UI/排序/筛选/列设置)
错误请求列表(/admin/usage 错误 tab、Ops 弹窗、用户端 /usage 错误 tab) 对齐用量明细的交互与信息密度。Squash of: - feat(usage): 错误请求全面对齐用量明细(UI/列序/排序/筛选/列设置/新列) - refactor(usage): 错误表提取共享徽章工具与 IP 批量工具条,后端排序解析归并 SetSort - feat(usage): /admin/usage 错误请求新增分类过滤 - fix(ops): 错误列表 phase=upstream 过滤生效,守卫豁免改为显式 opt-in - fix(ops): 错误列表用户列回退显示已删除 KEY 所有者 - fix(usage): 错误请求状态码排序对齐展示/过滤,筛选项改固定常用码
This commit is contained in:
@@ -121,24 +121,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<!-- Request Type Filter (usage only) -->
|
||||
<div v-if="mode !== 'errors'" 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="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<!-- Billing Type Filter (usage only) -->
|
||||
<div v-if="mode !== 'errors'" 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="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Mode Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<!-- Billing Mode Filter (usage only) -->
|
||||
<div v-if="mode !== 'errors'" 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="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Error Phase Filter (errors only) -->
|
||||
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('admin.ops.errorLog.type') }}</label>
|
||||
<Select v-model="filters.error_phase" :options="errorPhaseOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Error Category Filter (errors only) -->
|
||||
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.errors.category') }}</label>
|
||||
<Select v-model="filters.error_category" :options="errorCategoryOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Status Code Filter (errors only) -->
|
||||
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('admin.ops.errorLog.status') }}</label>
|
||||
<Select v-model="filters.status_code" :options="statusCodeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Group Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.group') }}</label>
|
||||
@@ -156,12 +174,14 @@
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<slot name="after-reset" />
|
||||
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
||||
{{ t('admin.usage.cleanup.button') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
|
||||
{{ t('usage.exportExcel') }}
|
||||
</button>
|
||||
<template v-if="mode !== 'errors'">
|
||||
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
||||
{{ t('admin.usage.cleanup.button') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
|
||||
{{ t('usage.exportExcel') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,6 +192,7 @@ import { ref, onMounted, onUnmounted, toRef, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import Select, { type SelectOption } from '@/components/common/Select.vue'
|
||||
import { COMMON_ERROR_STATUS_CODES } from '@/utils/errorBadges'
|
||||
import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage'
|
||||
|
||||
type ModelValue = Record<string, any>
|
||||
@@ -183,10 +204,13 @@ interface Props {
|
||||
endDate: string
|
||||
showActions?: boolean
|
||||
modelOptions?: string[]
|
||||
/** errors 模式:隐藏用量专属字段/按钮,显示错误类型+状态码(错误请求 tab 用) */
|
||||
mode?: 'usage' | 'errors'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showActions: true
|
||||
showActions: true,
|
||||
mode: 'usage'
|
||||
})
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
@@ -243,6 +267,29 @@ const billingTypeOptions = ref<SelectOption[]>([
|
||||
{ value: 1, label: t('admin.usage.billingTypeSubscription') }
|
||||
])
|
||||
|
||||
// 错误类型对应后端 phase 参数(与错误表"类型"徽章同语义)
|
||||
const errorPhaseOptions = computed<SelectOption[]>(() => [
|
||||
{ value: null, label: t('admin.usage.allTypes') },
|
||||
{ value: 'upstream', label: t('admin.ops.errorLog.typeUpstream') },
|
||||
{ value: 'request', label: t('admin.ops.errorLog.typeRequest') },
|
||||
{ value: 'auth', label: t('admin.ops.errorLog.typeAuth') },
|
||||
{ value: 'routing', label: t('admin.ops.errorLog.typeRouting') },
|
||||
{ value: 'internal', label: t('admin.ops.errorLog.typeInternal') },
|
||||
])
|
||||
|
||||
// 分类码同用户端 /usage 错误筛选;"other" 无法反查为过滤条件,刻意不列
|
||||
const errorCategoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal', 'cyber']
|
||||
|
||||
const errorCategoryOptions = computed<SelectOption[]>(() => [
|
||||
{ value: null, label: t('usage.errors.allCategories') },
|
||||
...errorCategoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
|
||||
])
|
||||
|
||||
const statusCodeOptions = computed<SelectOption[]>(() => [
|
||||
{ value: null, label: t('usage.errors.allStatuses') },
|
||||
...COMMON_ERROR_STATUS_CODES.map((c) => ({ value: c, label: String(c) })),
|
||||
])
|
||||
|
||||
const billingModeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allBillingModes') },
|
||||
{ value: 'token', label: t('admin.usage.billingModeToken') },
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
v-for="(row, index) in sortedData"
|
||||
:key="resolveRowKey(row, index)"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
|
||||
:class="{ 'cursor-pointer': clickableRows }"
|
||||
@click="clickableRows && emit('rowClick', row)"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
@@ -166,6 +168,8 @@
|
||||
:data-index="virtualRow.index"
|
||||
:ref="measureElement"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
:class="{ 'cursor-pointer': clickableRows }"
|
||||
@click="clickableRows && emit('rowClick', sortedData[virtualRow.index])"
|
||||
>
|
||||
<td
|
||||
v-for="(column, colIndex) in columns"
|
||||
@@ -214,6 +218,7 @@ const isDesktopViewport = ref(
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [key: string, order: 'asc' | 'desc']
|
||||
rowClick: [row: any]
|
||||
}>()
|
||||
|
||||
// 表格容器引用
|
||||
@@ -381,6 +386,8 @@ interface Props {
|
||||
* will emit 'sort' events instead of performing client-side sorting.
|
||||
*/
|
||||
serverSideSort?: boolean
|
||||
/** Emit 'rowClick' on row/card click and show pointer cursor (interactive cells should @click.stop) */
|
||||
clickableRows?: boolean
|
||||
/** Estimated row height in px for the virtualizer (default 56) */
|
||||
estimateRowHeight?: number
|
||||
/** Number of rows to render beyond the visible area (default 5) */
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="uniqueIps.length > 0"
|
||||
class="flex flex-shrink-0 items-center justify-end gap-2 border-b border-gray-200 px-4 py-2 dark:border-dark-700"
|
||||
>
|
||||
<span v-if="pendingCount > 0" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('usage.ipGeo.pending', { count: pendingCount }) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:bg-primary-900/30"
|
||||
:disabled="loading || pendingCount === 0"
|
||||
@click="run"
|
||||
>
|
||||
{{ loading ? t('usage.ipGeo.batchFetching') : t('usage.ipGeo.batchFetch') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchBatch, getEntry } from '@/utils/ipGeoLookup'
|
||||
|
||||
// 当前页 IP 批量地理查询工具条:传入原始 IP 列表(可含空值),内部去重;
|
||||
// 无 IP 时自身不渲染。批量失败 emit failed,由使用方弹提示。
|
||||
const props = defineProps<{
|
||||
ips: Array<string | null | undefined>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'failed'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const uniqueIps = computed(() =>
|
||||
Array.from(new Set(props.ips.filter((ip): ip is string => Boolean(ip))))
|
||||
)
|
||||
|
||||
const pendingCount = computed(() =>
|
||||
uniqueIps.value.filter((ip) => {
|
||||
const status = getEntry(ip).status
|
||||
return status === 'idle' || status === 'error'
|
||||
}).length
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const run = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const ok = await fetchBatch(uniqueIps.value)
|
||||
if (!ok) emit('failed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,91 +1,119 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="px-6 py-4 flex-shrink-0">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.errors.model') }}</label>
|
||||
<Select
|
||||
v-model="localModel"
|
||||
:options="modelOptions"
|
||||
searchable
|
||||
creatable
|
||||
clearable
|
||||
:placeholder="t('usage.errors.modelPlaceholder')"
|
||||
@change="apply"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-[160px]">
|
||||
<label class="input-label">{{ t('usage.errors.keyName') }}</label>
|
||||
<Select
|
||||
v-model="localApiKeyId"
|
||||
:options="keyOptions"
|
||||
:placeholder="t('usage.errors.allKeys')"
|
||||
@change="apply"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-[140px]">
|
||||
<label class="input-label">{{ t('usage.errors.category') }}</label>
|
||||
<Select
|
||||
v-model="localCategory"
|
||||
:options="categoryOptions"
|
||||
:placeholder="t('usage.errors.allCategories')"
|
||||
@change="apply"
|
||||
/>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="apply">
|
||||
<Icon name="search" size="sm" />
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<IpGeoBatchToolbar :ips="rows.map((r) => r.client_ip)" @failed="emit('ipGeoBatchFailed')" />
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left">{{ t('usage.errors.model') }}</th>
|
||||
<th class="px-4 py-2 text-left">{{ t('usage.errors.keyName') }}</th>
|
||||
<th class="px-4 py-2 text-left">{{ t('usage.errors.endpoint') }}</th>
|
||||
<th class="px-4 py-2 text-left">{{ t('usage.errors.status') }}</th>
|
||||
<th class="px-4 py-2 text-left">{{ t('usage.errors.category') }}</th>
|
||||
<th class="px-4 py-2 text-left">{{ t('usage.errors.message') }}</th>
|
||||
<th class="px-4 py-2 text-left">{{ t('usage.errors.platform') }}</th>
|
||||
<th class="px-4 py-2 text-left">{{ t('usage.errors.time') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, i) in rows"
|
||||
:key="i"
|
||||
class="border-t border-gray-100 dark:border-dark-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
@click="openDetail(row.id)"
|
||||
>
|
||||
<td class="px-4 py-2">{{ row.model || '-' }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span>{{ row.key_name || '-' }}</span>
|
||||
<span
|
||||
v-if="row.key_deleted"
|
||||
class="ml-1 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400"
|
||||
>{{ t('usage.errors.keyDeleted') }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">{{ row.inbound_endpoint || '-' }}</td>
|
||||
<td class="px-4 py-2"><span class="badge" :class="statusClass(row.status_code)">{{ row.status_code || '-' }}</span></td>
|
||||
<td class="px-4 py-2">{{ t('usage.errors.categories.' + row.category) }}</td>
|
||||
<td class="px-4 py-2 max-w-[280px] truncate" :title="row.message">{{ row.message || '-' }}</td>
|
||||
<td class="px-4 py-2">{{ row.platform || '-' }}</td>
|
||||
<td class="px-4 py-2">{{ formatDateTime(row.created_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && rows.length === 0">
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-400">{{ t('usage.errors.empty') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
clickable-rows
|
||||
server-side-sort
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="onSort"
|
||||
@rowClick="(row) => openDetail(row.id)"
|
||||
>
|
||||
<template #cell-model="{ row }">
|
||||
<span v-if="row.model" class="text-sm font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-key_name="{ row }">
|
||||
<div class="text-sm">
|
||||
<span class="text-gray-900 dark:text-white">{{ row.key_name || '-' }}</span>
|
||||
<span
|
||||
v-if="row.key_deleted"
|
||||
class="ml-1 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30"
|
||||
>{{ t('usage.errors.keyDeleted') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-endpoint="{ row }">
|
||||
<div class="max-w-[320px] text-xs">
|
||||
<div class="break-all text-gray-700 dark:text-gray-300">
|
||||
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.inbound') }}:</span>
|
||||
<span class="ml-1">{{ row.inbound_endpoint?.trim() || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ row }">
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="statusClass(row.status_code)">
|
||||
{{ row.status_code || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-category="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ t('usage.errors.categories.' + row.category) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-message="{ row }">
|
||||
<span
|
||||
v-if="row.message"
|
||||
class="block max-w-[280px] truncate text-sm text-gray-600 dark:text-gray-400"
|
||||
:title="row.message"
|
||||
>{{ row.message }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-group="{ row }">
|
||||
<span
|
||||
v-if="row.group_name"
|
||||
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
|
||||
>{{ row.group_name }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ row }">
|
||||
<span
|
||||
v-if="requestTypeBadge(row)"
|
||||
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="requestTypeBadge(row)!.className"
|
||||
>{{ requestTypeBadge(row)!.label }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-platform="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ row.platform || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-client_ip="{ row }">
|
||||
<div @click.stop>
|
||||
<div v-if="row.client_ip">
|
||||
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.client_ip }}</span>
|
||||
<IpGeoCell :ip="row.client_ip" />
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ row }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(row.created_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-user_agent="{ row }">
|
||||
<span
|
||||
v-if="row.user_agent"
|
||||
class="block max-w-[320px] truncate text-sm text-gray-600 dark:text-gray-400"
|
||||
:title="row.user_agent"
|
||||
>{{ row.user_agent }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #empty><EmptyState :message="t('usage.errors.empty')" /></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
<Pagination :page="page" :page-size="pageSize" :total="total"
|
||||
<Pagination
|
||||
v-if="total > 0"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@update:page="$emit('update:page', $event)"
|
||||
@update:pageSize="$emit('update:pageSize', $event)" />
|
||||
@update:pageSize="$emit('update:pageSize', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UserErrorDetailModal v-model:show="showDetail" :error-id="selectedId" />
|
||||
@@ -95,12 +123,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import UserErrorDetailModal from '@/components/user/UserErrorDetailModal.vue'
|
||||
import IpGeoCell from '@/components/common/IpGeoCell.vue'
|
||||
import IpGeoBatchToolbar from '@/components/common/IpGeoBatchToolbar.vue'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { UserErrorRequest, ApiKey } from '@/types'
|
||||
import {
|
||||
mapErrorSortKey,
|
||||
numericRequestTypeKind,
|
||||
requestTypeBadgeClass,
|
||||
requestTypeLabelKey,
|
||||
statusCodeBadgeClass,
|
||||
} from '@/utils/errorBadges'
|
||||
import type { UserErrorRequest } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: UserErrorRequest[]
|
||||
@@ -108,46 +146,51 @@ const props = defineProps<{
|
||||
loading: boolean
|
||||
page: number
|
||||
pageSize: number
|
||||
apiKeys?: ApiKey[]
|
||||
/** 列设置:仅显示这些 key 的列;不传则全显(key 须与 allColumns 一致) */
|
||||
visibleColumnKeys?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:page', v: number): void
|
||||
(e: 'update:pageSize', v: number): void
|
||||
(e: 'filter', v: { model: string; category: string; api_key_id: number | null }): void
|
||||
(e: 'ipGeoBatchFailed'): void
|
||||
(e: 'sort', sortBy: string, sortOrder: 'asc' | 'desc'): void
|
||||
}>()
|
||||
|
||||
function onSort(key: string, order: 'asc' | 'desc') {
|
||||
emit('sort', mapErrorSortKey(key), order)
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
// string | null:clearable 清空时 Select 回传 null,apply 中归一为空串
|
||||
const localModel = ref<string | null>('')
|
||||
const localCategory = ref<string>('')
|
||||
const localApiKeyId = ref<number | null>(null)
|
||||
|
||||
const categoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal', 'cyber']
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
{ value: '', label: t('usage.errors.allCategories') },
|
||||
...categoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
|
||||
// 列序对齐用户端用量明细:Key → 模型 → 端点 → IP → 分组 → 类型 → 平台 → 分类
|
||||
// → 结果(状态→消息)→ 时间 → UA(用量明细 UA 同在时间之后的尾部)
|
||||
const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'key_name', label: t('usage.errors.keyName') },
|
||||
{ key: 'model', label: t('usage.errors.model'), sortable: true },
|
||||
{ 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'), sortable: true },
|
||||
{ key: 'message', label: t('usage.errors.message') },
|
||||
{ key: 'created_at', label: t('usage.errors.time'), sortable: true },
|
||||
{ key: 'user_agent', label: t('usage.userAgent') },
|
||||
])
|
||||
|
||||
// 首项 value: null 表示不按 key 过滤;其余项取自父组件传入的 apiKeys 候选列表。
|
||||
const keyOptions = computed(() => [
|
||||
{ value: null, label: t('usage.errors.allKeys') },
|
||||
...(props.apiKeys ?? []).map((k) => ({ value: k.id, label: k.name })),
|
||||
])
|
||||
const columns = computed<Column[]>(() =>
|
||||
props.visibleColumnKeys
|
||||
? allColumns.value.filter((c) => props.visibleColumnKeys!.includes(c.key))
|
||||
: allColumns.value
|
||||
)
|
||||
|
||||
// 模型候选取自当前已加载错误中出现过的模型;creatable 允许输入任意片段做后端模糊。
|
||||
const modelOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
const opts: { value: string; label: string }[] = []
|
||||
for (const r of props.rows) {
|
||||
if (r.model && !seen.has(r.model)) {
|
||||
seen.add(r.model)
|
||||
opts.push({ value: r.model, label: r.model })
|
||||
}
|
||||
}
|
||||
return opts
|
||||
})
|
||||
function requestTypeBadge(row: UserErrorRequest): { label: string; className: string } | null {
|
||||
const kind = numericRequestTypeKind(row.request_type, row.stream)
|
||||
if (!kind) return null
|
||||
return { label: t(requestTypeLabelKey(kind)), className: requestTypeBadgeClass(kind) }
|
||||
}
|
||||
|
||||
const showDetail = ref(false)
|
||||
const selectedId = ref<number | null>(null)
|
||||
@@ -157,17 +200,5 @@ function openDetail(id: number) {
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
function apply() {
|
||||
emit('filter', {
|
||||
model: (localModel.value ?? '').trim(),
|
||||
category: localCategory.value || '',
|
||||
api_key_id: localApiKeyId.value,
|
||||
})
|
||||
}
|
||||
|
||||
function statusClass(code: number) {
|
||||
if (code >= 500) return 'badge-danger'
|
||||
if (code === 429) return 'badge-warning'
|
||||
return 'badge-gray'
|
||||
}
|
||||
const statusClass = statusCodeBadgeClass
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user