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:
DaydreamCoding
2026-07-03 23:02:27 +08:00
parent 2649573b99
commit ebbdc70311
25 changed files with 1108 additions and 459 deletions
+92 -5
View File
@@ -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>