mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 16:29:29 +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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user