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:
committed by
QTom
co-authored by
Claude Opus 4.8
parent
ddf063352a
commit
cfb195c7b2
@@ -22,6 +22,18 @@
|
||||
{{ selectedLabel }}
|
||||
</slot>
|
||||
</span>
|
||||
<span
|
||||
v-if="clearable && hasValue && !disabled"
|
||||
class="select-clear"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Clear selection"
|
||||
@click.stop="clearSelection"
|
||||
@mousedown.stop
|
||||
@keydown.enter.stop.prevent="clearSelection"
|
||||
>
|
||||
<Icon name="x" size="sm" />
|
||||
</span>
|
||||
<span class="select-icon">
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
@@ -135,6 +147,7 @@ interface Props {
|
||||
labelKey?: string
|
||||
creatable?: boolean
|
||||
creatablePrefix?: string
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -148,6 +161,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
searchable: 'auto',
|
||||
creatable: false,
|
||||
creatablePrefix: '',
|
||||
clearable: false,
|
||||
valueKey: 'value',
|
||||
labelKey: 'label'
|
||||
})
|
||||
@@ -239,6 +253,10 @@ const selectedLabel = computed(() => {
|
||||
return placeholderText.value
|
||||
})
|
||||
|
||||
const hasValue = computed(
|
||||
() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== ''
|
||||
)
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
let opts = props.options as any[]
|
||||
if (isSearchable.value && searchQuery.value) {
|
||||
@@ -355,6 +373,12 @@ const selectOption = (option: any) => {
|
||||
triggerRef.value?.focus()
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
if (props.disabled) return
|
||||
emit('update:modelValue', null)
|
||||
emit('change', null, null)
|
||||
}
|
||||
|
||||
// Keyboards
|
||||
const onTriggerKeyDown = () => {
|
||||
if (!isOpen.value) {
|
||||
@@ -461,6 +485,12 @@ onUnmounted(() => {
|
||||
.select-icon {
|
||||
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.select-clear {
|
||||
@apply flex flex-shrink-0 cursor-pointer items-center justify-center;
|
||||
@apply rounded text-gray-400 transition-colors;
|
||||
@apply hover:text-gray-600 dark:hover:text-gray-200;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('usage.errors.detail.title')" width="wide" @close="emit('update:show', false)">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<svg class="h-7 w-7 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="loadError" class="py-8 text-center text-sm text-red-500">
|
||||
{{ t('usage.errors.detail.loadFailed') }}
|
||||
</div>
|
||||
|
||||
<!-- Detail content -->
|
||||
<div v-else-if="detail" class="space-y-4 text-sm">
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
<!-- Time -->
|
||||
<div>
|
||||
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.time') }}</span>
|
||||
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ formatDateTime(detail.created_at) }}</p>
|
||||
</div>
|
||||
<!-- Model -->
|
||||
<div>
|
||||
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.model') }}</span>
|
||||
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ detail.model || '-' }}</p>
|
||||
</div>
|
||||
<!-- Endpoint -->
|
||||
<div>
|
||||
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.endpoint') }}</span>
|
||||
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ detail.inbound_endpoint || '-' }}</p>
|
||||
</div>
|
||||
<!-- Status Code -->
|
||||
<div>
|
||||
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.status') }}</span>
|
||||
<p class="mt-0.5">
|
||||
<span class="badge" :class="statusClass(detail.status_code)">{{ detail.status_code || '-' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.category') }}</span>
|
||||
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ t('usage.errors.categories.' + detail.category) }}</p>
|
||||
</div>
|
||||
<!-- Platform -->
|
||||
<div>
|
||||
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.platform') }}</span>
|
||||
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ detail.platform || '-' }}</p>
|
||||
</div>
|
||||
<!-- Upstream status code -->
|
||||
<div v-if="detail.upstream_status_code != null">
|
||||
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.detail.upstreamStatus') }}</span>
|
||||
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ detail.upstream_status_code }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div v-if="detail.message">
|
||||
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.message') }}</span>
|
||||
<p class="mt-0.5 text-gray-900 dark:text-dark-100 break-all">{{ detail.message }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Body -->
|
||||
<div v-if="detail.error_body">
|
||||
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.detail.responseBody') }}</span>
|
||||
<pre class="mt-1 overflow-auto max-h-[40vh] whitespace-pre-wrap break-all rounded-lg bg-gray-50 dark:bg-dark-900 border border-gray-200 dark:border-dark-700 p-3 text-xs text-gray-800 dark:text-dark-200">{{ detail.error_body }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { getMyErrorDetail } from '@/api/usage'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { UserErrorRequestDetail } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
errorId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', v: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const detail = ref<UserErrorRequestDetail | null>(null)
|
||||
|
||||
watch(
|
||||
() => [props.show, props.errorId] as const,
|
||||
([show, id]) => {
|
||||
if (show && id != null) {
|
||||
fetchDetail(id)
|
||||
} else if (!show) {
|
||||
detail.value = null
|
||||
loadError.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function fetchDetail(id: number) {
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await getMyErrorDetail(id)
|
||||
} catch (e) {
|
||||
console.error('[UserErrorDetailModal] Failed to load error detail:', e)
|
||||
loadError.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(code: number) {
|
||||
if (code >= 500) return 'badge-danger'
|
||||
if (code === 429) return 'badge-warning'
|
||||
return 'badge-gray'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
<Pagination :page="page" :page-size="pageSize" :total="total"
|
||||
@update:page="$emit('update:page', $event)"
|
||||
@update:pageSize="$emit('update:pageSize', $event)" />
|
||||
</div>
|
||||
|
||||
<UserErrorDetailModal v-model:show="showDetail" :error-id="selectedId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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 { formatDateTime } from '@/utils/format'
|
||||
import type { UserErrorRequest, ApiKey } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: UserErrorRequest[]
|
||||
total: number
|
||||
loading: boolean
|
||||
page: number
|
||||
pageSize: number
|
||||
apiKeys?: ApiKey[]
|
||||
}>()
|
||||
|
||||
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
|
||||
}>()
|
||||
|
||||
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']
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
{ value: '', label: t('usage.errors.allCategories') },
|
||||
...categoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
|
||||
])
|
||||
|
||||
// 首项 value: null 表示不按 key 过滤;其余项取自父组件传入的 apiKeys 候选列表。
|
||||
const keyOptions = computed(() => [
|
||||
{ value: null, label: t('usage.errors.allKeys') },
|
||||
...(props.apiKeys ?? []).map((k) => ({ value: k.id, label: k.name })),
|
||||
])
|
||||
|
||||
// 模型候选取自当前已加载错误中出现过的模型;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
|
||||
})
|
||||
|
||||
const showDetail = ref(false)
|
||||
const selectedId = ref<number | null>(null)
|
||||
|
||||
function openDetail(id: number) {
|
||||
selectedId.value = id
|
||||
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'
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user