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
+2
View File
@@ -1084,6 +1084,8 @@ export type OpsErrorListQueryParams = {
platform?: string
group_id?: number | null
account_id?: number | null
user_id?: number
api_key_id?: number
phase?: string
error_owner?: string
+5
View File
@@ -612,6 +612,9 @@ export interface SystemSettings {
// OpenAI fast/flex policy
openai_fast_policy_settings?: OpenAIFastPolicySettings;
// Allow user view error requests
allow_user_view_error_requests: boolean;
}
export interface UpdateSettingsRequest {
@@ -842,6 +845,8 @@ export interface UpdateSettingsRequest {
// OpenAI fast/flex policy
openai_fast_policy_settings?: OpenAIFastPolicySettings;
allow_user_view_error_requests?: boolean;
}
/**
+24 -2
View File
@@ -10,7 +10,10 @@ import type {
UsageStatsResponse,
PaginatedResponse,
TrendDataPoint,
ModelStat
ModelStat,
UserErrorRequest,
UserErrorRequestDetail,
UserErrorListParams
} from '@/types'
// ==================== Dashboard Types ====================
@@ -304,6 +307,22 @@ export async function getDashboardApiKeysUsage(
return data
}
export async function listMyErrorRequests(
params: UserErrorListParams,
config: { signal?: AbortSignal } = {}
): Promise<PaginatedResponse<UserErrorRequest>> {
const { data } = await apiClient.get<PaginatedResponse<UserErrorRequest>>('/usage/errors', {
...config,
params
})
return data
}
export async function getMyErrorDetail(id: number): Promise<UserErrorRequestDetail> {
const { data } = await apiClient.get<UserErrorRequestDetail>(`/usage/errors/${id}`)
return data
}
export const usageAPI = {
list,
query,
@@ -316,7 +335,10 @@ export const usageAPI = {
getDashboardTrend,
getDashboardModels,
getMyApiKeyDailyUsage,
getDashboardApiKeysUsage
getDashboardApiKeysUsage,
// Error requests
listMyErrorRequests,
getMyErrorDetail,
}
export default usageAPI
+30
View File
@@ -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>
+28 -1
View File
@@ -933,7 +933,26 @@ export default {
exportExcelSuccess: 'Usage data exported successfully (Excel format)',
exportExcelFailed: 'Failed to export usage data',
imageUnit: ' images',
userAgent: 'User-Agent'
userAgent: 'User-Agent',
tabs: { usage: 'Usage', errors: 'Error Requests' },
errors: {
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',
empty: 'No error requests', failedToLoad: 'Failed to load error requests',
categories: {
auth: 'Auth failed', rate_limit: 'Rate limited', quota: 'Balance/Subscription',
invalid_request: 'Invalid request', service_unavailable: 'Service unavailable',
upstream: 'Upstream error', internal: 'Platform error', other: 'Other',
},
detail: {
title: 'Error Request Detail',
responseBody: 'Response Body',
upstreamStatus: 'Upstream Status',
loadFailed: 'Failed to load detail, please try again',
},
},
},
// Shared keys for channel monitor (admin + user views)
@@ -6332,6 +6351,14 @@ export default {
title: 'OpenAI experimental scheduler policy',
description: "Disabled by default. When enabled, this only changes the gateway's experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability."
},
usageRecords: {
title: 'Usage Records',
description: 'Settings for usage and failed-request records visible to end users.',
},
user_error_view: {
label: 'Allow users to view their own error requests',
description: 'When enabled, users can see a redacted view of their failed requests on the usage page (no internal/upstream details). Requires ops monitoring enabled to have data.',
},
saveSettings: 'Save Settings',
saving: 'Saving...',
settingsSaved: 'Settings saved successfully',
+28 -1
View File
@@ -937,7 +937,26 @@ export default {
exportExcelSuccess: '使用数据导出成功(Excel格式)',
exportExcelFailed: '使用数据导出失败',
imageUnit: '张',
userAgent: 'User-Agent'
userAgent: 'User-Agent',
tabs: { usage: '用量明细', errors: '错误请求' },
errors: {
time: '时间', model: '模型', endpoint: '端点', status: '状态码',
category: '分类', platform: '平台', message: '错误信息',
keyName: 'Key 名称', keyDeleted: '已删除', allKeys: '全部 Key',
modelPlaceholder: '搜索模型', allCategories: '全部分类',
empty: '暂无错误请求', failedToLoad: '加载错误请求失败',
categories: {
auth: '认证失败', rate_limit: '限流', quota: '余额/订阅',
invalid_request: '参数错误', service_unavailable: '服务暂时不可用',
upstream: '上游错误', internal: '平台错误', other: '其他',
},
detail: {
title: '错误请求详情',
responseBody: '上游响应内容',
upstreamStatus: '上游状态码',
loadFailed: '加载详情失败,请稍后重试',
},
},
},
// Shared keys for channel monitor (admin + user views)
@@ -6487,6 +6506,14 @@ export default {
title: 'OpenAI 实验调度策略',
description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。'
},
usageRecords: {
title: '使用记录',
description: '与终端用户可见的用量及失败请求记录相关的设置。',
},
user_error_view: {
label: '允许用户查看自己的错误请求',
description: '开启后,用户可在用量页查看自己失败请求的精简信息(不含内部/上游错误细节)。需运维监控开启才有数据。',
},
saveSettings: '保存设置',
saving: '保存中...',
settingsSaved: '设置保存成功',
+1
View File
@@ -359,6 +359,7 @@ export const useAppStore = defineStore('app', () => {
available_channels_enabled: false,
risk_control_enabled: false,
affiliate_enabled: false,
allow_user_view_error_requests: false,
}
}
+31
View File
@@ -234,6 +234,7 @@ export interface PublicSettings {
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
affiliate_enabled: boolean
allow_user_view_error_requests?: boolean
}
export interface AuthResponse {
@@ -1592,6 +1593,36 @@ export interface ExtendSubscriptionRequest {
// ==================== Query Parameters ====================
export interface UserErrorRequest {
id: number
created_at: string
model: string
inbound_endpoint: string
status_code: number
category: string
platform: string
message: string
key_name: string
key_deleted: boolean
}
export interface UserErrorRequestDetail extends UserErrorRequest {
error_body: string
upstream_status_code?: number
}
export interface UserErrorListParams {
page?: number
page_size?: number
start_date?: string
end_date?: string
timezone?: string
model?: string
status_code?: number
category?: string
api_key_id?: number
}
export interface UsageQueryParams {
page?: number
page_size?: number
+32
View File
@@ -4398,6 +4398,35 @@
</div>
</div>
</div>
<!-- Usage Records Settings -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.usageRecords.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.usageRecords.description') }}
</p>
</div>
<div class="space-y-4 p-6">
<!-- User error requests visibility -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.user_error_view.label') }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.user_error_view.description') }}
</p>
</div>
<label class="toggle">
<input v-model="form.allow_user_view_error_requests" type="checkbox" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- /Tab: Gateway — Claude Code, Scheduling -->
@@ -7190,6 +7219,8 @@ const form = reactive<SettingsForm>({
available_channels_enabled: false,
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
// Allow user view error requests
allow_user_view_error_requests: false,
});
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
@@ -8331,6 +8362,7 @@ async function saveSettings() {
available_channels_enabled: form.available_channels_enabled,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
allow_user_view_error_requests: form.allow_user_view_error_requests,
};
// 仅当 openai_fast_policy_settings 已成功从后端加载时才回写,
+82 -11
View File
@@ -100,17 +100,36 @@
</div>
</template>
</UsageFilters>
<UsageTable
:data="usageLogs"
:loading="loading"
:columns="visibleColumns"
:server-side-sort="true"
:default-sort-key="'created_at'"
:default-sort-order="'desc'"
@sort="handleSort"
@userClick="handleUserClick"
/>
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
<div class="mb-4 flex gap-2 border-b border-gray-200 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="switchToErrorsTab">
{{ t('usage.tabs.errors') }}
</button>
</div>
<div v-show="activeTab === 'usage'">
<UsageTable
:data="usageLogs"
:loading="loading"
:columns="visibleColumns"
:server-side-sort="true"
:default-sort-key="'created_at'"
:default-sort-order="'desc'"
@sort="handleSort"
@userClick="handleUserClick"
/>
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
</div>
<div v-show="activeTab === 'errors'">
<OpsErrorLogTable
:rows="errRows" :total="errTotal" :loading="errLoading"
:page="errPage" :page-size="errPageSize"
@openErrorDetail="openError"
@update:page="onErrPage"
@update:pageSize="onErrPageSize" />
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="'request'" />
</div>
</div>
</AppLayout>
<UsageExportProgress :show="exportProgress.show" :progress="exportProgress.progress" :current="exportProgress.current" :total="exportProgress.total" :estimated-time="exportProgress.estimatedTime" @cancel="cancelExport" />
@@ -144,6 +163,10 @@ import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; impo
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue'
import OpsErrorLogTable from '@/views/admin/ops/components/OpsErrorLogTable.vue'
import OpsErrorDetailModal from '@/views/admin/ops/components/OpsErrorDetailModal.vue'
import { listErrorLogs } from '@/api/admin/ops'
import type { OpsErrorLog } from '@/api/admin/ops'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import GroupDistributionChart from '@/components/charts/GroupDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import EndpointDistributionChart from '@/components/charts/EndpointDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
@@ -432,6 +455,12 @@ const applyFilters = () => {
loadStats()
loadModelStats(modelDistributionSource.value, true)
loadChartData()
errPage.value = 1
if (activeTab.value === 'errors') {
loadAdminErrors()
} else {
errRows.value = []
}
}
const refreshData = () => {
invalidateModelStatsCache()
@@ -439,6 +468,7 @@ const refreshData = () => {
loadStats(true)
loadModelStats(modelDistributionSource.value, true)
loadChartData()
if (activeTab.value === 'errors') loadAdminErrors()
}
const resetFilters = () => {
const range = getLast24HoursRangeDates()
@@ -592,6 +622,47 @@ const loadSavedColumns = () => {
}
}
// Error tab state
const activeTab = ref<'usage' | 'errors'>('usage')
const errRows = ref<OpsErrorLog[]>([])
const errLoading = ref(false)
const errPage = ref(1)
const errPageSize = ref(20)
const errTotal = ref(0)
const showErrorModal = ref(false)
const selectedErrorId = ref<number | null>(null)
// 注意:'YYYY-MM-DDT00:00:00' 无时区后缀,按本地时区解析后再转 UTC——与页面其它日期处理语义一致,刻意如此,勿改成 'T00:00:00Z'
const toRFC3339 = (d: string | undefined, endOfDay = false): string | undefined =>
d ? new Date(d + (endOfDay ? 'T23:59:59.999' : 'T00:00:00')).toISOString() : undefined
const loadAdminErrors = async () => {
errLoading.value = true
try {
const resp = await listErrorLogs({
page: errPage.value,
page_size: errPageSize.value,
view: 'all',
start_time: toRFC3339(filters.value.start_date),
end_time: toRFC3339(filters.value.end_date, true),
user_id: filters.value.user_id ?? undefined,
api_key_id: filters.value.api_key_id ?? undefined,
})
errRows.value = resp.items
errTotal.value = resp.total
} catch (error) {
console.error('Failed to load admin errors:', error)
appStore.showError(t('usage.errors.failedToLoad'))
} finally {
errLoading.value = false
}
}
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 }
const switchToErrorsTab = () => { activeTab.value = 'errors'; if (errRows.value.length === 0) loadAdminErrors() }
const showColumnDropdown = ref(false)
const columnDropdownRef = ref<HTMLElement | null>(null)
+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()