fix(usage): 管理端错误请求页过滤与分列完善

- 错误请求标签补传 model/account_id/group_id 过滤(此前 loadAdminErrors 丢弃),admin handler
  读取 model 查询参数走精确匹配
- 错误表格拆成 用户/API Key/账号 三独立列(上游行也显示用户),补 api_key_name/api_key_deleted,
  已删除 key 显示红色「已删除」标记;i18n keyDeletedBadge 补入 errorLog 命名空间

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaydreamCoding 2026-06-05 10:19:41 +08:00 committed by QTom
parent cf12bc521b
commit fe8952733a
8 changed files with 215 additions and 19 deletions

View File

@ -110,6 +110,9 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
filter.UserQuery = strings.TrimSpace(c.Query("user_query"))
// Model 过滤admin 走精确匹配ModelFuzzy 默认 false保持管理端语义
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
@ -227,6 +230,9 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
filter.UserQuery = strings.TrimSpace(c.Query("user_query"))
// Model 过滤admin 走精确匹配ModelFuzzy 默认 false保持管理端语义
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.

View File

@ -907,6 +907,9 @@ export interface OpsErrorLog {
user_id?: number | null
user_email: string
api_key_id?: number | null
// 关联 api_key 名称(后端 LEFT JOIN api_keys软删保留 name故已删 key 仍有原名)。
api_key_name?: string
api_key_deleted?: boolean
account_id?: number | null
account_name: string
group_id?: number | null
@ -1086,6 +1089,8 @@ export type OpsErrorListQueryParams = {
account_id?: number | null
user_id?: number
api_key_id?: number
// 模型过滤:后端以 COALESCE(requested_model, model) 精确匹配admin 路径)。
model?: string
phase?: string
error_owner?: string

View File

@ -4776,6 +4776,8 @@ export default {
group: 'Group',
user: 'User',
userId: 'User ID',
apiKey: 'API Key',
keyDeletedBadge: 'Key Deleted',
account: 'Account',
accountId: 'Account ID',
status: 'Status',

View File

@ -4935,6 +4935,8 @@ export default {
group: '分组',
user: '用户',
userId: '用户 ID',
apiKey: 'API Key',
keyDeletedBadge: 'Key 已删除',
account: '账号',
accountId: '账号 ID',
status: '状态码',

View File

@ -647,6 +647,9 @@ const loadAdminErrors = async () => {
end_time: toRFC3339(filters.value.end_date, true),
user_id: filters.value.user_id ?? undefined,
api_key_id: filters.value.api_key_id ?? undefined,
account_id: filters.value.account_id ?? undefined,
group_id: filters.value.group_id ?? undefined,
model: filters.value.model || undefined,
})
errRows.value = resp.items
errTotal.value = resp.total

View File

@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import UsageView from '../UsageView.vue'
const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(() => {
const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
@ -16,6 +16,7 @@ const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(()
getSnapshotV2: vi.fn(),
getById: vi.fn(),
getModelStats: vi.fn(),
listErrorLogs: vi.fn(),
}
})
@ -55,6 +56,10 @@ vi.mock('@/api/admin/usage', () => ({
},
}))
vi.mock('@/api/admin/ops', () => ({
listErrorLogs,
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
@ -289,3 +294,61 @@ describe('admin UsageView handleUserClick', () => {
expect(getById).toHaveBeenCalledWith(2, true)
})
})
describe('admin UsageView errors tab filter forwarding', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset()
getStats.mockReset()
getSnapshotV2.mockReset()
getModelStats.mockReset()
listErrorLogs.mockReset()
list.mockResolvedValue({ items: [], total: 0, pages: 0 })
getStats.mockResolvedValue({
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
})
getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] })
getModelStats.mockResolvedValue({ models: [] })
listErrorLogs.mockResolvedValue({ items: [], total: 0, pages: 0 })
})
afterEach(() => {
vi.useRealTimers()
})
it('forwards model/account_id/group_id to listErrorLogs on the errors tab', async () => {
const wrapper = mount(UsageView, {
global: { stubs: {
AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub,
UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true,
UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true,
DateRangePicker: true, Icon: true, TokenUsageTrend: true,
ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true,
OpsErrorLogTable: true, OpsErrorDetailModal: true,
} },
})
vi.advanceTimersByTime(120)
await flushPromises()
// 模拟用户在过滤器里选择了模型/账户/分组
const vm = wrapper.vm as any
vm.filters.model = 'gpt-5.3-codex'
vm.filters.account_id = 7
vm.filters.group_id = 3
await flushPromises()
// 切换到「错误请求」标签(第二个 .tab 按钮)触发 loadAdminErrors
const tabs = wrapper.findAll('button.tab')
await tabs[1].trigger('click')
await flushPromises()
expect(listErrorLogs).toHaveBeenCalledWith(expect.objectContaining({
view: 'all',
model: 'gpt-5.3-codex',
account_id: 7,
group_id: 3,
}))
})
})

View File

@ -32,6 +32,12 @@
<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>
@ -45,7 +51,7 @@
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
<tr v-if="rows.length === 0">
<td colspan="10" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
<td colspan="12" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
{{ t('admin.ops.errorLog.noErrors') }}
</td>
</tr>
@ -127,24 +133,40 @@
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- User / Account -->
<!-- User -->
<td class="px-4 py-2">
<template v-if="isUpstreamRow(log)">
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
<span class="max-w-[100px] 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>
</template>
<template v-else>
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
<span class="max-w-[100px] 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>
</template>
<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 -->

View File

@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import OpsErrorLogTable from '../OpsErrorLogTable.vue'
import zhLocale from '@/i18n/locales/zh'
import enLocale from '@/i18n/locales/en'
import type { OpsErrorLog } from '@/api/admin/ops'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
const TooltipStub = { template: '<div><slot /></div>' }
const PaginationStub = { template: '<div class="pagination-stub" />' }
function mountTable(row: Partial<OpsErrorLog>) {
const base = {
id: 1,
created_at: '2026-06-05T23:59:50Z',
phase: 'upstream',
type: '',
error_owner: 'provider',
error_source: 'upstream_http',
severity: 'error',
status_code: 529,
platform: 'anthropic',
model: 'claude-opus-4-8',
resolved: false,
client_request_id: '',
request_id: 'req-1',
message: 'boom',
user_email: '',
account_name: '',
group_name: '',
...row,
} as OpsErrorLog
return mount(OpsErrorLogTable, {
props: { rows: [base], total: 1, loading: false, page: 1, pageSize: 20 },
global: { stubs: { 'el-tooltip': TooltipStub, Pagination: PaginationStub } },
})
}
describe('OpsErrorLogTable user/api-key/account columns', () => {
// 回归:上游错误行(phase=upstream, owner=provider)以前在单一「用户」列里只显示账号、
// 丢失用户;现在用户/API Key/账号各占独立列,三者同时可见。
it('renders user, api key and account in separate columns for an upstream row', () => {
const wrapper = mountTable({
user_id: 2,
user_email: 'alice@test.com',
api_key_id: 5,
api_key_name: 'my-key',
account_id: 9,
account_name: 'acct-A',
})
const text = wrapper.text()
expect(text).toContain('alice@test.com') // 用户列(上游行也显示用户)
expect(text).toContain('my-key') // API Key 列
expect(text).toContain('acct-A') // 账号列
})
it('shows the deleted badge for a soft-deleted api key', () => {
const wrapper = mountTable({
api_key_id: 5,
api_key_name: 'old-key',
api_key_deleted: true,
})
expect(wrapper.text()).toContain('old-key')
expect(wrapper.text()).toContain('admin.ops.errorLog.keyDeletedBadge')
})
})
// 防回归:组件用 admin.ops.errorLog.* 命名空间。若 i18n 键写错命名空间(如误放到
// errorDetail),真实 vue-i18n 会回退返回 key 本身 → 界面显示原始路径字符串。
// 这里用真实 locale 校验键确实可解析(返回译文而非 key)。
// 防回归:组件用 admin.ops.errorLog.* 命名空间。若键写错命名空间(如误放到
// errorDetail),界面会显示原始路径字符串而非译文。vitest 的 vue-i18n 为 runtime-only
// (无消息编译器,t() 对任何键都回退返回 key),故直接校验 locale 对象的命名空间含这些键。
describe('OpsErrorLogTable i18n keys exist in the errorLog namespace', () => {
const locales: Record<string, any> = { zh: zhLocale, en: enLocale }
for (const [name, msgs] of Object.entries(locales)) {
it(`has apiKey & keyDeletedBadge for ${name}`, () => {
const errorLog = msgs?.admin?.ops?.errorLog
expect(errorLog?.apiKey).toBeTruthy()
expect(errorLog?.keyDeletedBadge).toBeTruthy()
})
}
})