feat(ops): show account names in request details table

This commit is contained in:
yan9651688
2026-10-08 01:10:22 -07:00
parent 5fc0e486c3
commit ba280b3d5e
5 changed files with 94 additions and 5 deletions
@@ -428,6 +428,7 @@ export default {
kind: 'Kind',
platform: 'Platform',
model: 'Model',
account: 'Account',
duration: 'Duration',
status: 'Status',
requestId: 'Request ID',
@@ -428,6 +428,7 @@ export default {
kind: '类型',
platform: '平台',
model: '模型',
account: '账号',
duration: '耗时',
status: '状态码',
requestId: '请求ID',
@@ -8,6 +8,7 @@ import { useClipboard } from '@/composables/useClipboard'
import { useAppStore } from '@/stores'
import { opsAPI, type OpsRequestDetailsParams, type OpsRequestDetail } from '@/api/admin/ops'
import { parseTimeRangeMinutes, formatDateTime } from '../utils/opsFormatters'
import { getAccountNameMap } from '../utils/accountNameMap'
export interface OpsRequestDetailsPreset {
title: string
@@ -44,6 +45,7 @@ const items = ref<OpsRequestDetail[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const accountNameById = ref<Map<number, string>>(new Map())
const close = () => emit('update:modelValue', false)
@@ -93,6 +95,7 @@ const fetchData = async () => {
const res = await opsAPI.listRequestDetails(params)
items.value = res.items || []
total.value = res.total || 0
refreshAccountNames()
} catch (e: any) {
console.error('[OpsRequestDetailsModal] Failed to fetch request details', e)
appStore.showError(e?.message || t('admin.ops.requestDetails.failedToLoad'))
@@ -159,6 +162,27 @@ const kindBadgeClass = (kind: string) => {
if (kind === 'error') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
}
// 明细行只带 account_id;账号名来自 lite 账号列表映射,缺项时强制刷新一次缓存(新建账号场景)。
async function refreshAccountNames() {
const ids = items.value
.map(row => row.account_id)
.filter((id): id is number => typeof id === 'number' && id > 0)
if (ids.length === 0) return
try {
let map = await getAccountNameMap()
if (ids.some(id => !map.has(id))) map = await getAccountNameMap(true)
accountNameById.value = map
} catch (e) {
console.warn('[OpsRequestDetailsModal] Failed to load account names', e)
}
}
function accountLabel(row: OpsRequestDetail): string {
const id = row.account_id
if (typeof id !== 'number' || id <= 0) return '-'
return accountNameById.value.get(id) || `#${id}`
}
</script>
<template>
@@ -212,6 +236,9 @@ const kindBadgeClass = (kind: string) => {
<span class="ml-auto text-[11px] text-gray-500 dark:text-gray-400">{{ formatDateTime(row.created_at) }}</span>
</div>
<div class="break-all text-xs text-gray-600 dark:text-gray-300">{{ row.model || '-' }}</div>
<div v-if="typeof row.account_id === 'number' && row.account_id > 0" class="break-all text-xs text-gray-500 dark:text-gray-400" :title="accountLabel(row)">
{{ t('admin.ops.requestDetails.table.account') }}: {{ accountLabel(row) }}
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-600 dark:text-gray-300">
<span>{{ latencyLabel }}: {{ formatLatency(row) }}</span>
<span>{{ row.status_code ?? '-' }}</span>
@@ -251,6 +278,9 @@ const kindBadgeClass = (kind: string) => {
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.model') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.table.account') }}
</th>
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ latencyLabel }}
</th>
@@ -281,6 +311,9 @@ const kindBadgeClass = (kind: string) => {
<td class="max-w-[240px] truncate px-4 py-3 text-xs text-gray-600 dark:text-gray-300" :title="row.model || ''">
{{ row.model || '-' }}
</td>
<td class="max-w-[180px] truncate px-4 py-3 text-xs text-gray-600 dark:text-gray-300" :title="accountLabel(row)">
{{ accountLabel(row) }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
{{ formatLatency(row) }}
</td>
@@ -4,14 +4,17 @@ import type { OpsDashboardOverview } from '@/api/admin/ops'
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import OpsDashboardHeader from '../OpsDashboardHeader.vue'
import OpsRequestDetailsModal from '../OpsRequestDetailsModal.vue'
import { resetAccountNameCacheForTests } from '../../utils/accountNameMap'
const { listRequestDetails, viewport } = vi.hoisted(() => ({
const { listRequestDetails, listAccounts, viewport } = vi.hoisted(() => ({
listRequestDetails: vi.fn(),
listAccounts: vi.fn(),
viewport: { desktop: true },
}))
vi.mock('@vueuse/core', () => ({ useMediaQuery: () => ref(viewport.desktop) }))
vi.mock('@/api/admin/ops', () => ({ opsAPI: { listRequestDetails } }))
vi.mock('@/api/admin/accounts', () => ({ accountsAPI: { list: listAccounts } }))
vi.mock('@/api', () => ({ adminAPI: { groups: { getAll: vi.fn().mockResolvedValue([]) } } }))
vi.mock('@/stores', () => ({
useAppStore: () => ({ showError: vi.fn() }),
@@ -36,15 +39,23 @@ async function openDetails(sort: 'duration_desc' | 'ttft_desc') {
describe('Ops request latency details', () => {
beforeEach(() => {
vi.clearAllMocks()
resetAccountNameCacheForTests()
viewport.desktop = true
listRequestDetails.mockResolvedValue({
items: [
{ kind: 'success', created_at: '2026-09-10T00:00:00Z', duration_ms: 12000, first_token_ms: 800 },
{ kind: 'success', created_at: '2026-09-10T00:00:01Z', duration_ms: 9000, first_token_ms: 0 },
{ kind: 'success', created_at: '2026-09-10T00:00:02Z', duration_ms: 5000, first_token_ms: null },
{ kind: 'success', created_at: '2026-09-10T00:00:00Z', duration_ms: 12000, first_token_ms: 800, account_id: 101 },
{ kind: 'success', created_at: '2026-09-10T00:00:01Z', duration_ms: 9000, first_token_ms: 0, account_id: 102 },
{ kind: 'success', created_at: '2026-09-10T00:00:02Z', duration_ms: 5000, first_token_ms: null, account_id: 999 },
],
total: 3,
})
listAccounts.mockResolvedValue({
items: [
{ id: 101, name: 'acc-alpha' },
{ id: 102, name: 'acc-beta' },
],
total: 2,
})
})
it('opens the TTFT card with first-token sorting and successful requests', async () => {
@@ -74,11 +85,21 @@ describe('Ops request latency details', () => {
expect(wrapper.text()).not.toContain('12000 ms')
expect(wrapper.text()).not.toContain('9000 ms')
expect(wrapper.text()).not.toContain('5000 ms')
if (desktop) expect(wrapper.findAll('tbody tr')[2].findAll('td')[4].text()).toBe('-')
if (desktop) expect(wrapper.findAll('tbody tr')[2].findAll('td')[5].text()).toBe('-')
else expect(wrapper.text()).toContain('admin.ops.ttftLabel: -')
wrapper.unmount()
})
it('shows account names with id fallback in the account column', async () => {
const wrapper = await openDetails('ttft_desc')
expect(wrapper.text()).toContain('admin.ops.requestDetails.table.account')
const accountCells = wrapper.findAll('tbody tr').map(tr => tr.findAll('td')[4].text())
expect(accountCells).toEqual(['acc-alpha', 'acc-beta', '#999'])
// 999 不在账号映射里,应触发一次强制刷新重建缓存
expect(listAccounts).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('keeps total duration for duration details', async () => {
const wrapper = await openDetails('duration_desc')
expect(listRequestDetails).toHaveBeenCalledWith(expect.objectContaining({ sort: 'duration_desc' }))
@@ -0,0 +1,33 @@
import { accountsAPI } from '@/api/admin/accounts'
// 运维监控请求明细只带 account_id,账号名由前端用 lite 账号列表建映射;
// 模块级缓存避免每次打开弹窗都拉全量账号。
const ACCOUNT_NAME_PAGE_SIZE = 1000
let accountNameCache: Map<number, string> | null = null
export async function getAccountNameMap(force = false): Promise<Map<number, string>> {
if (!force && accountNameCache) return accountNameCache
const map = new Map<number, string>()
let page = 1
let total = Number.POSITIVE_INFINITY
while (map.size < total) {
const res = await accountsAPI.list(page, ACCOUNT_NAME_PAGE_SIZE, {
lite: '1',
include_scheduler_score: '0'
})
total = res.total || 0
const items = res.items || []
if (items.length === 0) break
for (const item of items) map.set(item.id, item.name)
page++
}
accountNameCache = map
return map
}
export function resetAccountNameCacheForTests() {
accountNameCache = null
}