feat(usage): 记录并展示失败请求(用户端+管理端)

- 记录失败请求并在用户端/管理端展示;分类下拉改用统一 Select 组件
- 模型过滤改后端 ILIKE 模糊匹配;新增「Key 名称」列(含已删除标记)与按 Key 过滤;时间列移至末列
- 用户可见「已删除 key 失败请求」:OpsErrorLogFilter 加 MatchDeletedKeyOwner,用户侧归属
  放宽为 (user_id OR deleted_key_owner_user_id),让 key 原所有者能看到删除 key 后继续请求
  导致的认证失败记录(他人仍 NotFound,不泄露存在性)
- 迁移 148:ops_error_logs 用户+时间索引

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaydreamCoding
2026-06-05 14:00:57 +08:00
committed by QTom
co-authored by Claude Opus 4.8
parent ddf063352a
commit cfb195c7b2
37 changed files with 1746 additions and 84 deletions
+84 -3
View File
@@ -149,7 +149,19 @@
</template>
<template #table>
<DataTable
<!-- Tab 切换栏 -->
<div v-if="errorViewEnabled" class="mb-0 flex gap-2 border-b border-gray-200 px-4 pt-3 dark:border-dark-700">
<button class="tab" :class="{ 'tab-active': activeTab === 'usage' }" @click="activeTab = 'usage'">
{{ t('usage.tabs.usage') }}
</button>
<button class="tab" :class="{ 'tab-active': activeTab === 'errors' }" @click="switchToErrors">
{{ t('usage.tabs.errors') }}
</button>
</div>
<!-- 用量明细表 -->
<div v-show="activeTab === 'usage'" class="flex min-h-0 flex-1 flex-col">
<DataTable
:columns="columns"
:data="usageLogs"
:loading="loading"
@@ -332,11 +344,27 @@
<EmptyState :message="t('usage.noRecords')" />
</template>
</DataTable>
</div>
<!-- 错误请求表 -->
<div v-if="errorViewEnabled" v-show="activeTab === 'errors'" class="flex min-h-0 flex-1 flex-col">
<UserErrorRequestsTable
:rows="errorRows"
:total="errorTotal"
:loading="errorLoading"
:page="errorPage"
:page-size="errorPageSize"
:api-keys="apiKeys"
@filter="onErrorFilter"
@update:page="onErrorPage"
@update:pageSize="onErrorPageSize"
/>
</div>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
v-if="pagination.total > 0 && activeTab === 'usage'"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@@ -550,7 +578,8 @@ import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Icon from '@/components/icons/Icon.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import UserErrorRequestsTable from '@/components/user/UserErrorRequestsTable.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse, UserErrorRequest } from '@/types'
import type { Column } from '@/components/common/types'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
@@ -653,6 +682,12 @@ const onDateRangeChange = (range: {
filters.value.start_date = range.startDate
filters.value.end_date = range.endDate
applyFilters()
errorPage.value = 1
if (activeTab.value === 'errors') {
loadErrors()
} else {
errorRows.value = [] // 失效,下次切到 errors tab 时按新日期重新加载
}
}
const pagination = reactive({
@@ -988,6 +1023,52 @@ const hideTokenTooltip = () => {
tokenTooltipData.value = null
}
// ── Error Requests Tab ──────────────────────────────────────────────────────
const activeTab = ref<'usage' | 'errors'>('usage')
const errorViewEnabled = computed(() => appStore.cachedPublicSettings?.allow_user_view_error_requests ?? false)
const errorRows = ref<UserErrorRequest[]>([])
const errorLoading = ref(false)
const errorPage = ref(1)
const errorPageSize = ref(20)
const errorTotal = ref(0)
const errorFilter = ref<{ model: string; category: string; api_key_id: number | null }>({ model: '', category: '', api_key_id: null })
const loadErrors = async () => {
errorLoading.value = true
try {
const resp = await usageAPI.listMyErrorRequests({
page: errorPage.value,
page_size: errorPageSize.value,
start_date: startDate.value,
end_date: endDate.value,
model: errorFilter.value.model || undefined,
category: errorFilter.value.category || undefined,
api_key_id: errorFilter.value.api_key_id ?? undefined,
})
errorRows.value = resp.items
errorTotal.value = resp.total
} catch (error) {
console.error('[UsageView] loadErrors failed:', error)
appStore.showError(t('usage.errors.failedToLoad'))
} finally {
errorLoading.value = false
}
}
const onErrorFilter = (f: { model: string; category: string; api_key_id: number | null }) => {
errorFilter.value = f
errorPage.value = 1
loadErrors()
}
const onErrorPage = (p: number) => { errorPage.value = p; loadErrors() }
const onErrorPageSize = (s: number) => { errorPageSize.value = s; errorPage.value = 1; loadErrors() }
const switchToErrors = () => {
activeTab.value = 'errors'
if (errorRows.value.length === 0) loadErrors()
}
onMounted(() => {
loadApiKeys()
loadUsageLogs()