mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 12:57:57 +08:00
Merge pull request #5480 from scp-planet/fix/admin-usage-request-id-column
修复管理端使用记录请求 ID 列显示 / Restore admin usage request ID column visibility
This commit is contained in:
@@ -238,6 +238,24 @@
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-request_id="{ row }">
|
||||
<div v-if="row.request_id" class="flex max-w-[160px] items-center gap-1.5">
|
||||
<span class="truncate font-mono text-xs text-gray-500 dark:text-gray-400" :title="row.request_id">
|
||||
{{ row.request_id }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
:class="copiedRequestId === row.request_id ? 'text-green-500 hover:text-green-500' : ''"
|
||||
:title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')"
|
||||
@click="copyRequestId(row.request_id)"
|
||||
>
|
||||
<Icon :name="copiedRequestId === row.request_id ? 'check' : 'copy'" size="sm" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-user_agent="{ row }">
|
||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] truncate" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
@@ -481,6 +499,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
|
||||
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
|
||||
@@ -560,6 +579,8 @@ const emit = defineEmits<{
|
||||
ipGeoBatchFailed: []
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const copiedRequestId = ref<string | null>(null)
|
||||
const showAccountBilling = props.showAccountBilling
|
||||
const showUpstreamEndpoint = props.showUpstreamEndpoint
|
||||
const ipGeoBatchLoading = ref(false)
|
||||
@@ -609,6 +630,19 @@ const handleBatchFetchIpGeo = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const copyRequestId = async (requestId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(requestId)
|
||||
copiedRequestId.value = requestId
|
||||
appStore.showSuccess(t('admin.usage.requestIdCopied'))
|
||||
window.setTimeout(() => {
|
||||
if (copiedRequestId.value === requestId) copiedRequestId.value = null
|
||||
}, 2000)
|
||||
} catch {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip state - cost
|
||||
const tooltipVisible = ref(false)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
@@ -4,7 +4,13 @@ const ipGeoMocks = vi.hoisted(() => ({
|
||||
fetchBatch: vi.fn(),
|
||||
}))
|
||||
|
||||
const appStoreMocks = vi.hoisted(() => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/ipGeoLookup', () => ipGeoMocks)
|
||||
vi.mock('@/stores/app', () => ({ useAppStore: () => appStoreMocks }))
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
@@ -51,6 +57,10 @@ const messages: Record<string, string> = {
|
||||
'admin.usage.billingModeToken': 'Token',
|
||||
'admin.usage.billingModePerRequest': 'Per request',
|
||||
'admin.usage.billingModeImage': 'Image',
|
||||
'admin.usage.requestIdCopied': 'Request ID copied',
|
||||
'keys.copied': 'Copied',
|
||||
'keys.copyToClipboard': 'Copy to clipboard',
|
||||
'common.copyFailed': 'Copy failed',
|
||||
'usage.requestedModel': 'Requested',
|
||||
'usage.sentUpstreamModel': 'Sent upstream',
|
||||
'usage.upstreamResponseModel': 'Upstream response',
|
||||
@@ -77,6 +87,7 @@ const DataTableStub = {
|
||||
<slot name="cell-billing_mode" :row="row" />
|
||||
<slot name="cell-tokens" :row="row" />
|
||||
<slot name="cell-cost" :row="row" />
|
||||
<slot name="cell-request_id" :row="row" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -410,6 +421,40 @@ describe('admin UsageTable tooltip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin UsageTable request ID column', () => {
|
||||
beforeEach(() => {
|
||||
appStoreMocks.showSuccess.mockReset()
|
||||
appStoreMocks.showError.mockReset()
|
||||
})
|
||||
|
||||
it('renders and copies the request ID', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
|
||||
const wrapper = mount(UsageTable, {
|
||||
props: {
|
||||
data: [{ ...baseImageRow, request_id: 'req-admin-visible-id' }],
|
||||
loading: false,
|
||||
columns: [{ key: 'request_id', label: 'Request ID' }],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
DataTable: DataTableStub,
|
||||
EmptyState: true,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('req-admin-visible-id')
|
||||
await wrapper.get('button[title="Copy to clipboard"]').trigger('click')
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('req-admin-visible-id')
|
||||
expect(appStoreMocks.showSuccess).toHaveBeenCalledWith('Request ID copied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin UsageTable IP geolocation batch toolbar', () => {
|
||||
const DataTableStubWithIp = {
|
||||
props: ['data'],
|
||||
|
||||
@@ -625,8 +625,10 @@ const exportToExcel = async () => {
|
||||
|
||||
// Column visibility
|
||||
const ALWAYS_VISIBLE = ['user', 'created_at']
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['reasoning_effort', 'user_agent']
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['reasoning_effort', 'request_id', 'user_agent']
|
||||
const HIDDEN_COLUMNS_KEY = 'usage-hidden-columns'
|
||||
const HIDDEN_COLUMNS_VERSION_KEY = 'usage-hidden-columns-version'
|
||||
const HIDDEN_COLUMNS_CURRENT_VERSION = 'request-id-hidden-by-default'
|
||||
|
||||
const allColumns = computed(() => [
|
||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||
@@ -642,6 +644,7 @@ const allColumns = computed(() => [
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'latency', label: t('usage.latency'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false },
|
||||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
|
||||
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
|
||||
])
|
||||
@@ -668,6 +671,7 @@ const toggleColumn = (key: string) => {
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION)
|
||||
} catch (e) {
|
||||
console.error('Failed to save columns:', e)
|
||||
}
|
||||
@@ -748,10 +752,16 @@ const loadSavedColumns = () => {
|
||||
(JSON.parse(saved) as string[]).forEach((key) => {
|
||||
hiddenColumns.add(key)
|
||||
})
|
||||
if (localStorage.getItem(HIDDEN_COLUMNS_VERSION_KEY) !== HIDDEN_COLUMNS_CURRENT_VERSION) {
|
||||
hiddenColumns.add('request_id')
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION)
|
||||
}
|
||||
} else {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach((key) => {
|
||||
hiddenColumns.add(key)
|
||||
})
|
||||
localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION)
|
||||
}
|
||||
} catch {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach((key) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ const messages: Record<string, string> = {
|
||||
'admin.dashboard.day': 'Day',
|
||||
'admin.dashboard.hour': 'Hour',
|
||||
'admin.usage.failedToLoadUser': 'Failed to load user',
|
||||
'admin.usage.requestId': 'Request ID',
|
||||
'usage.requestedModel': 'Requested model',
|
||||
'usage.sentUpstreamModel': 'Sent upstream model',
|
||||
'usage.upstreamResponseModel': 'Upstream response model',
|
||||
@@ -133,6 +134,7 @@ const UsageFiltersStub = defineComponent({
|
||||
template: '<div><span data-test="user-filter-label">{{ userKeyword }}</span><slot name="after-reset" /></div>',
|
||||
})
|
||||
const UsageTableStub = {
|
||||
props: ['columns'],
|
||||
emits: ['userClick'],
|
||||
template: '<div data-test="usage-table"><button class="user-click" @click="$emit(\'userClick\', 2)">user</button></div>',
|
||||
}
|
||||
@@ -393,6 +395,70 @@ describe('admin UsageView distribution metric toggles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin UsageView request ID column visibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(localStorage.getItem).mockReset().mockReturnValue(null)
|
||||
vi.mocked(localStorage.setItem).mockReset()
|
||||
list.mockReset().mockResolvedValue({ items: [], total: 0, pages: 0 })
|
||||
getStats.mockReset().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.mockReset().mockResolvedValue({ trend: [], models: [], groups: [] })
|
||||
getModelStats.mockReset().mockResolvedValue({ models: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps request ID hidden by default and allows enabling it from column settings', async () => {
|
||||
const wrapper = mount(UsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
UsageStatsCards: true,
|
||||
UsageFilters: UsageFiltersStub,
|
||||
UsageTable: UsageTableStub,
|
||||
UsageExportProgress: true,
|
||||
UsageCleanupDialog: true,
|
||||
UserBalanceHistoryModal: true,
|
||||
AuditLogModal: true,
|
||||
Pagination: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
Icon: true,
|
||||
TokenUsageTrend: true,
|
||||
ModelDistributionChart: true,
|
||||
GroupDistributionChart: true,
|
||||
EndpointDistributionChart: true,
|
||||
UserTokenRanking: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const usageTable = wrapper.findComponent(UsageTableStub)
|
||||
expect(usageTable.props('columns')).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ key: 'request_id' })]),
|
||||
)
|
||||
|
||||
await wrapper.get('button[title="admin.users.columnSettings"]').trigger('click')
|
||||
const requestIdToggle = wrapper.findAll('button').find((button) => button.text() === 'Request ID')
|
||||
expect(requestIdToggle).toBeDefined()
|
||||
await requestIdToggle!.trigger('click')
|
||||
|
||||
expect(usageTable.props('columns')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ key: 'request_id', label: 'Request ID' })]),
|
||||
)
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
'usage-hidden-columns-version',
|
||||
'request-id-hidden-by-default',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin UsageView handleUserClick', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
Reference in New Issue
Block a user