mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08: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:
@@ -57,6 +57,20 @@ if (typeof globalThis.cancelIdleCallback === 'undefined') {
|
||||
}) as unknown as typeof cancelIdleCallback
|
||||
}
|
||||
|
||||
// Mock matchMedia (jsdom 未实现;DataTable 等组件依赖它做桌面/移动分支)
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: true, // 测试默认按桌面视口渲染表格
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn()
|
||||
|
||||
@@ -930,11 +930,16 @@ export interface OpsErrorLog {
|
||||
requested_model?: string
|
||||
upstream_model?: string
|
||||
request_type?: number | null
|
||||
user_agent?: string
|
||||
|
||||
// 已删除 KEY 所有者(INVALID_API_KEY 归因快照):认证失败行 user_id 为空,
|
||||
// 用户列以此回退显示所有者
|
||||
deleted_key_owner_user_id?: number | null
|
||||
deleted_key_owner_email?: string | null
|
||||
}
|
||||
|
||||
export interface OpsErrorDetail extends OpsErrorLog {
|
||||
error_body: string
|
||||
user_agent: string
|
||||
|
||||
// Upstream context (optional; enriched by gateway services)
|
||||
upstream_status_code?: number | null
|
||||
@@ -950,10 +955,9 @@ export interface OpsErrorDetail extends OpsErrorLog {
|
||||
|
||||
is_business_limited: boolean
|
||||
|
||||
// Deleted key owner info (INVALID_API_KEY attribution)
|
||||
// Deleted key owner info (INVALID_API_KEY attribution);
|
||||
// owner user_id/email 已上移到 OpsErrorLog(列表用户列回退)
|
||||
attempted_key_prefix?: string | null
|
||||
deleted_key_owner_user_id?: number | null
|
||||
deleted_key_owner_email?: string | null
|
||||
deleted_key_name?: string | null
|
||||
|
||||
// Bound (non-deleted) key prefix, snapshotted at error time
|
||||
@@ -1098,6 +1102,8 @@ export type OpsErrorListQueryParams = {
|
||||
model?: string
|
||||
|
||||
phase?: string
|
||||
// 分类(用户侧粗分类码,如 auth/rate_limit/upstream),后端反查为 phase/type ANY 条件
|
||||
category?: string
|
||||
error_owner?: string
|
||||
error_source?: string
|
||||
resolved?: string
|
||||
@@ -1106,6 +1112,10 @@ export type OpsErrorListQueryParams = {
|
||||
q?: string
|
||||
status_codes?: string
|
||||
status_codes_other?: string
|
||||
|
||||
// 服务端排序,列白名单见后端 opsErrorLogsOrderBy(created_at/model/status_code)
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// Legacy unified endpoints
|
||||
|
||||
@@ -86,6 +86,10 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
billing_mode?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
// 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象)
|
||||
error_phase?: string | null
|
||||
error_category?: string | null
|
||||
status_code?: number | null
|
||||
}
|
||||
|
||||
// ==================== API Functions ====================
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1017,7 +1017,7 @@ export default {
|
||||
time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status',
|
||||
category: 'Category', platform: 'Platform', message: 'Message',
|
||||
keyName: 'Key Name', keyDeleted: 'Deleted', allKeys: 'All keys',
|
||||
modelPlaceholder: 'Search model', allCategories: 'All categories',
|
||||
modelPlaceholder: 'Search model', allCategories: 'All categories', allStatuses: 'All status codes',
|
||||
empty: 'No error requests', failedToLoad: 'Failed to load error requests',
|
||||
categories: {
|
||||
auth: 'Auth failed', rate_limit: 'Rate limited', quota: 'Balance/Subscription',
|
||||
@@ -5088,6 +5088,7 @@ export default {
|
||||
accountId: 'Account ID',
|
||||
status: 'Status',
|
||||
message: 'Message',
|
||||
ip: 'IP',
|
||||
latency: 'Request Duration',
|
||||
action: 'Action',
|
||||
noErrors: 'No errors in this window.',
|
||||
|
||||
@@ -1021,7 +1021,7 @@ export default {
|
||||
time: '时间', model: '模型', endpoint: '端点', status: '状态码',
|
||||
category: '分类', platform: '平台', message: '错误信息',
|
||||
keyName: 'Key 名称', keyDeleted: '已删除', allKeys: '全部 Key',
|
||||
modelPlaceholder: '搜索模型', allCategories: '全部分类',
|
||||
modelPlaceholder: '搜索模型', allCategories: '全部分类', allStatuses: '全部状态码',
|
||||
empty: '暂无错误请求', failedToLoad: '加载错误请求失败',
|
||||
categories: {
|
||||
auth: '认证失败', rate_limit: '限流', quota: '余额/订阅',
|
||||
@@ -5246,6 +5246,7 @@ export default {
|
||||
accountId: '账号 ID',
|
||||
status: '状态码',
|
||||
message: '响应内容',
|
||||
ip: 'IP',
|
||||
latency: '请求时长',
|
||||
action: '操作',
|
||||
noErrors: '该窗口内暂无错误。',
|
||||
|
||||
@@ -1688,6 +1688,11 @@ export interface UserErrorRequest {
|
||||
message: string
|
||||
key_name: string
|
||||
key_deleted: boolean
|
||||
client_ip?: string
|
||||
group_name?: string
|
||||
request_type?: number
|
||||
stream?: boolean
|
||||
user_agent?: string
|
||||
}
|
||||
|
||||
export interface UserErrorRequestDetail extends UserErrorRequest {
|
||||
@@ -1705,6 +1710,9 @@ export interface UserErrorListParams {
|
||||
status_code?: number
|
||||
category?: string
|
||||
api_key_id?: number
|
||||
// 服务端排序,列白名单见后端 opsErrorLogsOrderBy(created_at/model/status_code)
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface UsageQueryParams {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 错误请求/用量明细共享的徽章配色与列映射。
|
||||
* 配色统一 bg-X-100/text-X-800 体系,与 UsageTable 一致。
|
||||
*/
|
||||
|
||||
import type { UsageRequestType } from '@/types'
|
||||
|
||||
export type UsageRequestKind = UsageRequestType
|
||||
|
||||
/** 状态码徽章:≥500 红、429 紫、≥400 琥珀、其余灰 */
|
||||
export function statusCodeBadgeClass(code: number): string {
|
||||
if (code >= 500) return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
if (code === 429) return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
if (code >= 400) return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200'
|
||||
}
|
||||
|
||||
/** 请求类型徽章配色(cyber 红、ws 紫、stream 蓝、sync 灰、未知琥珀) */
|
||||
export function requestTypeBadgeClass(kind: UsageRequestKind): string {
|
||||
if (kind === 'cyber') return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
if (kind === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200'
|
||||
if (kind === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
if (kind === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200'
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}
|
||||
|
||||
/** 请求类型 i18n 键(展示方自行 t()) */
|
||||
export function requestTypeLabelKey(kind: UsageRequestKind): string {
|
||||
if (kind === 'cyber') return 'usage.cyber'
|
||||
if (kind === 'ws_v2') return 'usage.ws'
|
||||
if (kind === 'stream') return 'usage.stream'
|
||||
if (kind === 'sync') return 'usage.sync'
|
||||
return 'usage.unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字 request_type(1 同步/2 流式/3 WS)→ kind;
|
||||
* 缺失时按 stream 布尔回退,两者都缺返回 null(展示为 -)。
|
||||
*/
|
||||
export function numericRequestTypeKind(
|
||||
requestType?: number | null,
|
||||
stream?: boolean | null
|
||||
): UsageRequestKind | null {
|
||||
const rt = requestType ?? (stream == null ? 0 : stream ? 2 : 1)
|
||||
if (rt === 3) return 'ws_v2'
|
||||
if (rt === 2) return 'stream'
|
||||
if (rt === 1) return 'sync'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 错误表列 key → 后端 sort_by(status 列实际按 status_code 排序) */
|
||||
export function mapErrorSortKey(key: string): string {
|
||||
return key === 'status' ? 'status_code' : key
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误请求筛选的常用状态码固定候选(管理端 + 用户端共用)。
|
||||
* 用固定列表而非「当前页出现过的码」派生,避免目标状态码只在后续页/筛选外时无法选中
|
||||
* ——后端 status_code 过滤对全量数据生效,选项不应被当前页数据限制。
|
||||
*/
|
||||
export const COMMON_ERROR_STATUS_CODES = [400, 401, 403, 404, 408, 413, 429, 499, 500, 502, 503, 504, 529]
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 错误请求"分类"虚拟维度:phase + error type → 用户侧粗分类码。
|
||||
* 镜像后端 service.MapUserErrorCategory(backend/internal/service/ops_user_error.go),
|
||||
* 两处修改须同步。返回稳定分类码,展示文案走 i18n `usage.errors.categories.*`。
|
||||
*/
|
||||
export function mapErrorCategory(phase?: string | null, errType?: string | null): string {
|
||||
switch ((phase || '').toLowerCase()) {
|
||||
case 'auth':
|
||||
return 'auth'
|
||||
case 'routing':
|
||||
return 'service_unavailable'
|
||||
case 'upstream':
|
||||
case 'network':
|
||||
return 'upstream'
|
||||
case 'internal':
|
||||
return 'internal'
|
||||
case 'request':
|
||||
switch ((errType || '').toLowerCase()) {
|
||||
case 'rate_limit_error':
|
||||
return 'rate_limit'
|
||||
case 'billing_error':
|
||||
case 'subscription_error':
|
||||
return 'quota'
|
||||
case 'invalid_request_error':
|
||||
return 'invalid_request'
|
||||
case 'cyber_policy':
|
||||
return 'cyber'
|
||||
}
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
@@ -64,7 +64,7 @@
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
<UsageFilters v-model="filters" :start-date="startDate" :end-date="endDate" :exporting="exporting" :model-options="modelNameOptions" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
|
||||
<UsageFilters v-model="filters" :mode="activeTab === 'errors' ? 'errors' : 'usage'" :start-date="startDate" :end-date="endDate" :exporting="exporting" :model-options="modelNameOptions" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
|
||||
<template #after-reset>
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@@ -82,14 +82,14 @@
|
||||
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"
|
||||
v-for="col in currentToggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
@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="isColumnVisible(col.key)"
|
||||
v-if="isCurrentColumnVisible(col.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
@@ -126,9 +126,14 @@
|
||||
<OpsErrorLogTable
|
||||
:rows="errRows" :total="errTotal" :loading="errLoading"
|
||||
:page="errPage" :page-size="errPageSize"
|
||||
:visible-column-keys="errVisibleColumnKeys"
|
||||
user-clickable
|
||||
@userClick="handleUserClick"
|
||||
@openErrorDetail="openError"
|
||||
@sort="onErrSort"
|
||||
@update:page="onErrPage"
|
||||
@update:pageSize="onErrPageSize" />
|
||||
@update:pageSize="onErrPageSize"
|
||||
@ipGeoBatchFailed="handleIpGeoBatchFailed" />
|
||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="'request'" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -609,6 +614,74 @@ const toggleColumn = (key: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 错误请求 tab 列设置(与用量明细同机制,独立存储) ----
|
||||
const ERR_ALWAYS_VISIBLE = ['user', 'status', 'created_at', 'actions']
|
||||
const ERR_DEFAULT_HIDDEN_COLUMNS = ['user_agent']
|
||||
const ERR_HIDDEN_COLUMNS_KEY = 'usage-error-hidden-columns'
|
||||
|
||||
// key 集合须与 OpsErrorLogTable 内部 allColumns 一致
|
||||
const errAllColumns = computed(() => [
|
||||
{ key: 'user', label: t('admin.ops.errorLog.user') },
|
||||
{ key: 'api_key', label: t('admin.ops.errorLog.apiKey') },
|
||||
{ key: 'account', label: t('admin.ops.errorLog.account') },
|
||||
{ key: 'platform', label: t('admin.ops.errorLog.platform') },
|
||||
{ key: 'model', label: t('admin.ops.errorLog.model') },
|
||||
{ key: 'endpoint', label: t('admin.ops.errorLog.endpoint') },
|
||||
{ key: 'group', label: t('admin.ops.errorLog.group') },
|
||||
{ key: 'type', label: t('admin.ops.errorLog.type') },
|
||||
{ key: 'category', label: t('usage.errors.category') },
|
||||
{ key: 'status', label: t('admin.ops.errorLog.status') },
|
||||
{ key: 'message', label: t('admin.ops.errorLog.message') },
|
||||
{ key: 'created_at', label: t('admin.ops.errorLog.time') },
|
||||
{ key: 'user_agent', label: t('usage.userAgent') },
|
||||
{ key: 'client_ip', label: t('admin.ops.errorLog.ip') },
|
||||
{ key: 'actions', label: t('admin.ops.errorLog.action') },
|
||||
])
|
||||
|
||||
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 toggleErrColumn = (key: string) => {
|
||||
if (errHiddenColumns.has(key)) {
|
||||
errHiddenColumns.delete(key)
|
||||
} else {
|
||||
errHiddenColumns.add(key)
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(ERR_HIDDEN_COLUMNS_KEY, JSON.stringify([...errHiddenColumns]))
|
||||
} catch (e) {
|
||||
console.error('Failed to save error columns:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSavedErrColumns = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(ERR_HIDDEN_COLUMNS_KEY)
|
||||
const keys = saved ? (JSON.parse(saved) as string[]) : ERR_DEFAULT_HIDDEN_COLUMNS
|
||||
keys.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' ? !errHiddenColumns.has(key) : isColumnVisible(key)
|
||||
const toggleCurrentColumn = (key: string) =>
|
||||
activeTab.value === 'errors' ? toggleErrColumn(key) : toggleColumn(key)
|
||||
|
||||
const loadSavedColumns = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||
@@ -635,6 +708,8 @@ const errLoading = ref(false)
|
||||
const errPage = ref(1)
|
||||
const errPageSize = ref(20)
|
||||
const errTotal = ref(0)
|
||||
const errSortBy = ref('created_at')
|
||||
const errSortOrder = ref<'asc' | 'desc'>('desc')
|
||||
const showErrorModal = ref(false)
|
||||
const selectedErrorId = ref<number | null>(null)
|
||||
|
||||
@@ -656,6 +731,11 @@ const loadAdminErrors = async () => {
|
||||
account_id: filters.value.account_id ?? undefined,
|
||||
group_id: filters.value.group_id ?? undefined,
|
||||
model: filters.value.model || undefined,
|
||||
phase: filters.value.error_phase || undefined,
|
||||
category: filters.value.error_category || undefined,
|
||||
status_codes: filters.value.status_code != null ? String(filters.value.status_code) : undefined,
|
||||
sort_by: errSortBy.value,
|
||||
sort_order: errSortOrder.value,
|
||||
})
|
||||
errRows.value = resp.items
|
||||
errTotal.value = resp.total
|
||||
@@ -667,6 +747,12 @@ const loadAdminErrors = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onErrSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
|
||||
errSortBy.value = sortBy
|
||||
errSortOrder.value = sortOrder
|
||||
errPage.value = 1
|
||||
loadAdminErrors()
|
||||
}
|
||||
const onErrPage = (p: number) => { errPage.value = p; loadAdminErrors() }
|
||||
const onErrPageSize = (s: number) => { errPageSize.value = s; errPage.value = 1; loadAdminErrors() }
|
||||
const openError = (id: number) => { selectedErrorId.value = id; showErrorModal.value = true }
|
||||
@@ -690,6 +776,7 @@ onMounted(() => {
|
||||
void loadChartData()
|
||||
}, 120)
|
||||
loadSavedColumns()
|
||||
loadSavedErrColumns()
|
||||
document.addEventListener('click', handleColumnClickOutside)
|
||||
})
|
||||
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
|
||||
|
||||
@@ -84,6 +84,16 @@ function close() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
const sortBy = ref('created_at')
|
||||
const sortOrder = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
function onSort(nextSortBy: string, nextSortOrder: 'asc' | 'desc') {
|
||||
sortBy.value = nextSortBy
|
||||
sortOrder.value = nextSortOrder
|
||||
page.value = 1
|
||||
void fetchErrorLogs()
|
||||
}
|
||||
|
||||
async function fetchErrorLogs() {
|
||||
if (!props.show) return
|
||||
|
||||
@@ -93,7 +103,9 @@ async function fetchErrorLogs() {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
time_range: props.timeRange,
|
||||
view: viewMode.value
|
||||
view: viewMode.value,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value
|
||||
}
|
||||
|
||||
const platform = String(props.platform || '').trim()
|
||||
@@ -253,6 +265,7 @@ watch(
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@openErrorDetail="emit('openErrorDetail', $event)"
|
||||
@sort="onSort"
|
||||
|
||||
@update:page="page = $event"
|
||||
@update:pageSize="pageSize = $event"
|
||||
|
||||
@@ -1,272 +1,251 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-white dark:bg-dark-900">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center py-10">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="card flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<IpGeoBatchToolbar :ips="rows.map((r) => r.client_ip)" @failed="emit('ipGeoBatchFailed')" />
|
||||
|
||||
<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) => emit('openErrorDetail', row.id)"
|
||||
>
|
||||
<template #cell-created_at="{ row }">
|
||||
<span
|
||||
class="text-sm text-gray-600 dark:text-gray-400"
|
||||
:title="row.request_id || row.client_request_id"
|
||||
>{{ formatDateTime(row.created_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ row }">
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getTypeBadge(row).className">
|
||||
{{ getTypeBadge(row).label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-endpoint="{ row }">
|
||||
<div class="max-w-[320px] space-y-1 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 v-if="row.upstream_endpoint" class="break-all text-gray-700 dark:text-gray-300">
|
||||
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.upstream') }}:</span>
|
||||
<span class="ml-1">{{ row.upstream_endpoint?.trim() || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-platform="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ row.platform || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-model="{ row }">
|
||||
<div v-if="hasModelMapping(row)" class="space-y-0.5 text-xs">
|
||||
<div class="break-all font-medium text-gray-900 dark:text-white">{{ row.requested_model }}</div>
|
||||
<div class="break-all text-gray-500 dark:text-gray-400"><span class="mr-0.5">↳</span>{{ row.upstream_model }}</div>
|
||||
</div>
|
||||
<span v-else-if="displayModel(row)" class="text-sm font-medium text-gray-900 dark:text-white">{{ displayModel(row) }}</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_id"
|
||||
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"
|
||||
:title="t('admin.ops.errorLog.id') + ' ' + row.group_id"
|
||||
>
|
||||
{{ row.group_name || '#' + row.group_id }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-user="{ row }">
|
||||
<div v-if="row.user_id" class="text-sm">
|
||||
<button
|
||||
v-if="userClickable && row.user_email"
|
||||
class="font-medium text-primary-600 underline decoration-dashed underline-offset-2 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
:title="t('admin.usage.clickToViewBalance')"
|
||||
@click.stop="emit('userClick', row.user_id, row.user_email)"
|
||||
>
|
||||
{{ row.user_email }}
|
||||
</button>
|
||||
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.user_email || '-' }}</span>
|
||||
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span>
|
||||
</div>
|
||||
<!-- 认证失败行 user_id 为空:回退显示已删除 KEY 所有者(归因快照,与详情弹窗一致) -->
|
||||
<div v-else-if="row.deleted_key_owner_user_id" class="text-sm">
|
||||
<button
|
||||
v-if="userClickable && row.deleted_key_owner_email"
|
||||
class="font-medium text-primary-600 underline decoration-dashed underline-offset-2 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
:title="t('admin.usage.clickToViewBalance')"
|
||||
@click.stop="emit('userClick', row.deleted_key_owner_user_id, row.deleted_key_owner_email ?? undefined)"
|
||||
>
|
||||
{{ row.deleted_key_owner_email }}
|
||||
</button>
|
||||
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.deleted_key_owner_email || '-' }}</span>
|
||||
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.deleted_key_owner_user_id }}</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-api_key="{ row }">
|
||||
<div v-if="row.api_key_id || row.api_key_name" class="text-sm">
|
||||
<span class="text-gray-900 dark:text-white">{{ row.api_key_name || '#' + row.api_key_id }}</span>
|
||||
<span
|
||||
v-if="row.api_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('admin.ops.errorLog.keyDeletedBadge') }}</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-account="{ row }">
|
||||
<span
|
||||
v-if="row.account_id"
|
||||
class="text-sm text-gray-900 dark:text-white"
|
||||
:title="t('admin.ops.errorLog.accountId') + ' ' + row.account_id"
|
||||
>{{ row.account_name || '#' + row.account_id }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-category="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
{{ t('usage.errors.categories.' + mapErrorCategory(row.phase, row.type)) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ row }">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getStatusClass(row.status_code)">
|
||||
{{ row.status_code }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.severity"
|
||||
:class="['rounded px-1.5 py-0.5 text-[10px] font-medium', getSeverityClass(row.severity)]"
|
||||
>{{ row.severity }}</span>
|
||||
<span
|
||||
v-if="row.request_type != null && row.request_type > 0"
|
||||
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200"
|
||||
>{{ formatRequestType(row.request_type) }}</span>
|
||||
</div>
|
||||
</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"
|
||||
>{{ formatSmartMessage(row.message) || '-' }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</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 #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-actions="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-600 dark:hover:text-primary-400"
|
||||
:title="t('admin.ops.errorLog.details')"
|
||||
@click.stop="emit('openErrorDetail', row.id)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #empty><EmptyState :message="t('admin.ops.errorLog.noErrors')" /></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Table Container -->
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-auto border-b border-gray-200 dark:border-dark-700">
|
||||
<table class="w-full border-separate border-spacing-0">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-800">
|
||||
<tr>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.time') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.type') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.endpoint') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.platform') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.model') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.group') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.user') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.apiKey') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.account') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.status') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.message') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.action') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="12" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||
{{ t('admin.ops.errorLog.noErrors') }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
v-for="log in rows"
|
||||
:key="log.id"
|
||||
class="group cursor-pointer transition-colors hover:bg-gray-50/80 dark:hover:bg-dark-800/50"
|
||||
@click="emit('openErrorDetail', log.id)"
|
||||
>
|
||||
<!-- Time -->
|
||||
<td class="whitespace-nowrap px-4 py-2">
|
||||
<el-tooltip :content="log.request_id || log.client_request_id" placement="top" :show-after="500">
|
||||
<span class="font-mono text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ formatDateTime(log.created_at).split(' ')[1] }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</td>
|
||||
|
||||
<!-- Type -->
|
||||
<td class="whitespace-nowrap px-4 py-2">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
|
||||
getTypeBadge(log).className
|
||||
]"
|
||||
>
|
||||
{{ getTypeBadge(log).label }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Endpoint -->
|
||||
<td class="px-4 py-2">
|
||||
<div class="max-w-[160px]">
|
||||
<el-tooltip v-if="log.inbound_endpoint" :content="formatEndpointTooltip(log)" placement="top" :show-after="500">
|
||||
<span class="truncate font-mono text-[11px] text-gray-700 dark:text-gray-300">
|
||||
{{ log.inbound_endpoint }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Platform -->
|
||||
<td class="whitespace-nowrap px-4 py-2">
|
||||
<span class="inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-bold uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-300">
|
||||
{{ log.platform || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Model -->
|
||||
<td class="px-4 py-2">
|
||||
<div class="max-w-[160px]">
|
||||
<template v-if="hasModelMapping(log)">
|
||||
<el-tooltip :content="modelMappingTooltip(log)" placement="top" :show-after="500">
|
||||
<span class="flex items-center gap-1 truncate font-mono text-[11px] text-gray-700 dark:text-gray-300">
|
||||
<span class="truncate">{{ log.requested_model }}</span>
|
||||
<span class="flex-shrink-0 text-gray-400">→</span>
|
||||
<span class="truncate text-primary-600 dark:text-primary-400">{{ log.upstream_model }}</span>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="displayModel(log)" class="truncate font-mono text-[11px] text-gray-700 dark:text-gray-300" :title="displayModel(log)">
|
||||
{{ displayModel(log) }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Group -->
|
||||
<td class="px-4 py-2">
|
||||
<el-tooltip v-if="log.group_id" :content="t('admin.ops.errorLog.id') + ' ' + log.group_id" placement="top" :show-after="500">
|
||||
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.group_name || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
|
||||
<!-- User -->
|
||||
<td class="px-4 py-2">
|
||||
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
|
||||
<span class="block max-w-[140px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.user_email || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
|
||||
<!-- API Key -->
|
||||
<td class="px-4 py-2">
|
||||
<div v-if="log.api_key_id || log.api_key_name" class="flex max-w-[140px] items-center gap-1">
|
||||
<span class="truncate text-xs font-medium text-gray-900 dark:text-gray-200" :title="log.api_key_name || ('#' + log.api_key_id)">
|
||||
{{ log.api_key_name || ('#' + log.api_key_id) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.api_key_deleted"
|
||||
class="flex-shrink-0 rounded px-1 py-0.5 text-[9px] font-bold ring-1 ring-inset bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30"
|
||||
>
|
||||
{{ t('admin.ops.errorLog.keyDeletedBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
|
||||
<!-- Account -->
|
||||
<td class="px-4 py-2">
|
||||
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
|
||||
<span class="block max-w-[120px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.account_name || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="whitespace-nowrap px-4 py-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
|
||||
getStatusClass(log.status_code)
|
||||
]"
|
||||
>
|
||||
{{ log.status_code }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.severity"
|
||||
:class="['rounded px-1.5 py-0.5 text-[10px] font-bold', getSeverityClass(log.severity)]"
|
||||
>
|
||||
{{ log.severity }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.request_type != null && log.request_type > 0"
|
||||
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-bold text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||
>
|
||||
{{ formatRequestType(log.request_type) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Message (Response Content) -->
|
||||
<td class="px-4 py-2">
|
||||
<div class="max-w-[200px]">
|
||||
<p class="truncate text-[11px] font-medium text-gray-600 dark:text-gray-400" :title="log.message">
|
||||
{{ formatSmartMessage(log.message) || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="whitespace-nowrap px-4 py-2 text-right" @click.stop>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button type="button" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 text-xs font-bold" @click="emit('openErrorDetail', log.id)">
|
||||
{{ t('admin.ops.errorLog.details') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="bg-gray-50/50 dark:bg-dark-800/50">
|
||||
<Pagination
|
||||
v-if="total > 0"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@update:page="emit('update:page', $event)"
|
||||
@update:pageSize="emit('update:pageSize', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<Pagination
|
||||
v-if="total > 0"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@update:page="emit('update:page', $event)"
|
||||
@update:pageSize="emit('update:pageSize', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { 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 IpGeoCell from '@/components/common/IpGeoCell.vue'
|
||||
import IpGeoBatchToolbar from '@/components/common/IpGeoBatchToolbar.vue'
|
||||
import type { OpsErrorLog } from '@/api/admin/ops'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { getSeverityClass, formatDateTime } from '../utils/opsFormatters'
|
||||
import { mapErrorCategory } from '@/utils/errorCategory'
|
||||
import { mapErrorSortKey, statusCodeBadgeClass } from '@/utils/errorBadges'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 列序对齐管理端用量明细:身份(用户→Key→账号)→ 请求形态(平台→模型→端点→分组→类型)
|
||||
// → 结果(状态→消息)→ 时间→UA→IP→操作
|
||||
const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'user', label: t('admin.ops.errorLog.user') },
|
||||
{ key: 'api_key', label: t('admin.ops.errorLog.apiKey') },
|
||||
{ key: 'account', label: t('admin.ops.errorLog.account') },
|
||||
{ key: 'platform', label: t('admin.ops.errorLog.platform') },
|
||||
{ key: 'model', label: t('admin.ops.errorLog.model'), sortable: true },
|
||||
{ key: 'endpoint', label: t('admin.ops.errorLog.endpoint') },
|
||||
{ key: 'group', label: t('admin.ops.errorLog.group') },
|
||||
{ key: 'type', label: t('admin.ops.errorLog.type') },
|
||||
{ key: 'category', label: t('usage.errors.category') },
|
||||
{ key: 'status', label: t('admin.ops.errorLog.status'), sortable: true },
|
||||
{ key: 'message', label: t('admin.ops.errorLog.message') },
|
||||
{ key: 'created_at', label: t('admin.ops.errorLog.time'), sortable: true },
|
||||
{ key: 'user_agent', label: t('usage.userAgent') },
|
||||
{ key: 'client_ip', label: t('admin.ops.errorLog.ip') },
|
||||
{ key: 'actions', label: t('admin.ops.errorLog.action') },
|
||||
])
|
||||
|
||||
// 传入 visibleColumnKeys 时按其过滤(列设置);未传则全量(Ops 弹窗等使用方)
|
||||
const columns = computed<Column[]>(() =>
|
||||
props.visibleColumnKeys
|
||||
? allColumns.value.filter((c) => props.visibleColumnKeys!.includes(c.key))
|
||||
: allColumns.value
|
||||
)
|
||||
|
||||
function isUpstreamRow(log: OpsErrorLog): boolean {
|
||||
const phase = String(log.phase || '').toLowerCase()
|
||||
const owner = String(log.error_owner || '').toLowerCase()
|
||||
return phase === 'upstream' && owner === 'provider'
|
||||
}
|
||||
|
||||
function formatEndpointTooltip(log: OpsErrorLog): string {
|
||||
const parts: string[] = []
|
||||
if (log.inbound_endpoint) parts.push(`Inbound: ${log.inbound_endpoint}`)
|
||||
if (log.upstream_endpoint) parts.push(`Upstream: ${log.upstream_endpoint}`)
|
||||
return parts.join('\n') || ''
|
||||
}
|
||||
|
||||
function hasModelMapping(log: OpsErrorLog): boolean {
|
||||
const requested = String(log.requested_model || '').trim()
|
||||
const upstream = String(log.upstream_model || '').trim()
|
||||
return !!requested && !!upstream && requested !== upstream
|
||||
}
|
||||
|
||||
function modelMappingTooltip(log: OpsErrorLog): string {
|
||||
const requested = String(log.requested_model || '').trim()
|
||||
const upstream = String(log.upstream_model || '').trim()
|
||||
if (!requested && !upstream) return ''
|
||||
if (requested && upstream) return `${requested} → ${upstream}`
|
||||
return upstream || requested
|
||||
}
|
||||
|
||||
function displayModel(log: OpsErrorLog): string {
|
||||
const upstream = String(log.upstream_model || '').trim()
|
||||
if (upstream) return upstream
|
||||
@@ -284,28 +263,29 @@ function formatRequestType(type: number | null | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 徽章配色对齐用量明细(UsageTable)的 bg-X-100/text-X-800 体系
|
||||
function getTypeBadge(log: OpsErrorLog): { label: string; className: string } {
|
||||
const phase = String(log.phase || '').toLowerCase()
|
||||
const owner = String(log.error_owner || '').toLowerCase()
|
||||
|
||||
if (isUpstreamRow(log)) {
|
||||
return { label: t('admin.ops.errorLog.typeUpstream'), className: 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30' }
|
||||
return { label: t('admin.ops.errorLog.typeUpstream'), className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }
|
||||
}
|
||||
if (phase === 'request' && owner === 'client') {
|
||||
return { label: t('admin.ops.errorLog.typeRequest'), className: 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30' }
|
||||
return { label: t('admin.ops.errorLog.typeRequest'), className: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' }
|
||||
}
|
||||
if (phase === 'auth' && owner === 'client') {
|
||||
return { label: t('admin.ops.errorLog.typeAuth'), className: 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-500/30' }
|
||||
return { label: t('admin.ops.errorLog.typeAuth'), className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' }
|
||||
}
|
||||
if (phase === 'routing' && owner === 'platform') {
|
||||
return { label: t('admin.ops.errorLog.typeRouting'), className: 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30' }
|
||||
return { label: t('admin.ops.errorLog.typeRouting'), className: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' }
|
||||
}
|
||||
if (phase === 'internal' && owner === 'platform') {
|
||||
return { label: t('admin.ops.errorLog.typeInternal'), className: 'bg-gray-100 text-gray-800 ring-gray-600/20 dark:bg-dark-700 dark:text-gray-200 dark:ring-dark-500/40' }
|
||||
return { label: t('admin.ops.errorLog.typeInternal'), className: 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200' }
|
||||
}
|
||||
|
||||
const fallback = phase || owner || t('common.unknown')
|
||||
return { label: fallback, className: 'bg-gray-50 text-gray-700 ring-gray-600/10 dark:bg-dark-900 dark:text-gray-300 dark:ring-dark-700' }
|
||||
const fallback = phase || owner || t('common.unknown')
|
||||
return { label: fallback, className: 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-200' }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -314,24 +294,30 @@ interface Props {
|
||||
loading: boolean
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 用户邮箱可点击(emit userClick),仅在有弹窗承接的使用方开启 */
|
||||
userClickable?: boolean
|
||||
/** 列设置:仅显示这些 key 的列;不传则全量 */
|
||||
visibleColumnKeys?: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'openErrorDetail', id: number): void
|
||||
(e: 'update:page', value: number): void
|
||||
(e: 'update:pageSize', value: number): void
|
||||
(e: 'ipGeoBatchFailed'): void
|
||||
(e: 'sort', sortBy: string, sortOrder: 'asc' | 'desc'): void
|
||||
(e: 'userClick', userId: number, email?: string): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function getStatusClass(code: number): string {
|
||||
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
|
||||
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
|
||||
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
|
||||
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
|
||||
function onSort(key: string, order: 'asc' | 'desc') {
|
||||
emit('sort', mapErrorSortKey(key), order)
|
||||
}
|
||||
|
||||
const getStatusClass = statusCodeBadgeClass
|
||||
|
||||
function formatSmartMessage(msg: string): string {
|
||||
if (!msg) return ''
|
||||
|
||||
@@ -352,6 +338,5 @@ function formatSmartMessage(msg: string): string {
|
||||
if (msg.toLowerCase().includes('rate limit')) return t('admin.ops.errorLog.commonErrors.rateLimit')
|
||||
|
||||
return msg.length > 200 ? msg.substring(0, 200) + '...' : msg
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,33 @@
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="flex flex-1 flex-wrap items-end 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" />
|
||||
@@ -96,7 +122,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" @click="refreshData" :disabled="loading" class="btn btn-secondary">
|
||||
<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">
|
||||
@@ -117,18 +143,18 @@
|
||||
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"
|
||||
v-for="col in currentToggleableColumns"
|
||||
:key="col.key"
|
||||
type="button"
|
||||
@click="toggleColumn(col.key)"
|
||||
@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="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
||||
<Icon v-if="isCurrentColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
|
||||
<button v-if="activeTab !== 'errors'" type="button" @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
|
||||
{{ exporting ? t('usage.exporting') : t('usage.exportCsv') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -175,10 +201,11 @@
|
||||
:loading="errorLoading"
|
||||
:page="errorPage"
|
||||
:page-size="errorPageSize"
|
||||
:api-keys="apiKeys"
|
||||
@filter="onErrorFilter"
|
||||
:visible-column-keys="errVisibleColumnKeys"
|
||||
@sort="onErrorSort"
|
||||
@update:page="onErrorPage"
|
||||
@update:pageSize="onErrorPageSize"
|
||||
@ipGeoBatchFailed="handleIpGeoBatchFailed"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -219,6 +246,7 @@ import type {
|
||||
UserErrorRequest,
|
||||
} from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { COMMON_ERROR_STATUS_CODES } from '@/utils/errorBadges'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -244,13 +272,53 @@ 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; category: string; api_key_id: number | null }>({
|
||||
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
|
||||
@@ -486,6 +554,10 @@ const resetFilters = () => {
|
||||
}
|
||||
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 }) => {
|
||||
@@ -663,6 +735,63 @@ const loadSavedColumns = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 错误请求 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) => {
|
||||
@@ -702,9 +831,12 @@ const loadErrors = async () => {
|
||||
page_size: errorPageSize.value,
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
model: errorFilter.value.model || undefined,
|
||||
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
|
||||
@@ -716,8 +848,9 @@ const loadErrors = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onErrorFilter = (filter: { model: string; category: string; api_key_id: number | null }) => {
|
||||
errorFilter.value = filter
|
||||
const onErrorSort = (sortBy: string, sortOrder: 'asc' | 'desc') => {
|
||||
errorSortBy.value = sortBy
|
||||
errorSortOrder.value = sortOrder
|
||||
errorPage.value = 1
|
||||
void loadErrors()
|
||||
}
|
||||
@@ -740,6 +873,7 @@ const switchToErrors = () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadSavedColumns()
|
||||
loadSavedErrColumns()
|
||||
document.addEventListener('click', handleColumnClickOutside)
|
||||
void loadFilterOptions()
|
||||
refreshData()
|
||||
|
||||
Reference in New Issue
Block a user