feat(usage): add User-Agent column to usage logs

- Add user_agent field to UsageLog DTO and mapper
- Display User-Agent column in admin and user usage tables
- Add formatUserAgent helper to show friendly client names
- Include user_agent in Excel export
- Remove request_id column from admin usage table
This commit is contained in:
Edric Li
2026-01-08 21:02:13 +08:00
parent acabdc2f99
commit 70fcbd7006
8 changed files with 51 additions and 29 deletions

View File

@@ -266,6 +266,7 @@ func UsageLogFromService(l *service.UsageLog) *UsageLog {
FirstTokenMs: l.FirstTokenMs, FirstTokenMs: l.FirstTokenMs,
ImageCount: l.ImageCount, ImageCount: l.ImageCount,
ImageSize: l.ImageSize, ImageSize: l.ImageSize,
UserAgent: l.UserAgent,
CreatedAt: l.CreatedAt, CreatedAt: l.CreatedAt,
User: UserFromServiceShallow(l.User), User: UserFromServiceShallow(l.User),
APIKey: APIKeyFromService(l.APIKey), APIKey: APIKeyFromService(l.APIKey),

View File

@@ -180,6 +180,9 @@ type UsageLog struct {
ImageCount int `json:"image_count"` ImageCount int `json:"image_count"`
ImageSize *string `json:"image_size"` ImageSize *string `json:"image_size"`
// User-Agent
UserAgent *string `json:"user_agent"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
User *User `json:"user,omitempty"` User *User `json:"user,omitempty"`

View File

@@ -115,15 +115,9 @@
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span> <span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template> </template>
<template #cell-request_id="{ row }"> <template #cell-user_agent="{ row }">
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]"> <span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate" :title="row.request_id">{{ row.request_id }}</span> <span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
<button @click="copyRequestId(row.request_id)" class="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="copiedRequestId === row.request_id ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'" :title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')">
<svg v-if="copiedRequestId === row.request_id" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
<Icon v-else name="copy" size="sm" class="h-3.5 w-3.5" />
</button>
</div>
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
</template> </template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template> <template #empty><EmptyState :message="t('usage.noRecords')" /></template>
@@ -228,7 +222,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { useAppStore } from '@/stores/app'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
@@ -236,8 +229,6 @@ import type { UsageLog } from '@/types'
defineProps(['data', 'loading']) defineProps(['data', 'loading'])
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore()
const copiedRequestId = ref<string | null>(null)
// Tooltip state - cost // Tooltip state - cost
const tooltipVisible = ref(false) const tooltipVisible = ref(false)
@@ -262,7 +253,7 @@ const cols = computed(() => [
{ key: 'first_token', label: t('usage.firstToken'), sortable: false }, { key: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false }, { key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true }, { 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 }
]) ])
const formatCacheTokens = (tokens: number): string => { const formatCacheTokens = (tokens: number): string => {
@@ -271,23 +262,25 @@ const formatCacheTokens = (tokens: number): string => {
return tokens.toString() return tokens.toString()
} }
const formatUserAgent = (ua: string): string => {
// 提取主要客户端标识
if (ua.includes('claude-cli')) return ua.match(/claude-cli\/[\d.]+/)?.[0] || 'Claude CLI'
if (ua.includes('Cursor')) return 'Cursor'
if (ua.includes('VSCode') || ua.includes('vscode')) return 'VS Code'
if (ua.includes('Continue')) return 'Continue'
if (ua.includes('Cline')) return 'Cline'
if (ua.includes('OpenAI')) return 'OpenAI SDK'
if (ua.includes('anthropic')) return 'Anthropic SDK'
// 截断过长的 UA
return ua.length > 30 ? ua.substring(0, 30) + '...' : ua
}
const formatDuration = (ms: number | null | undefined): string => { const formatDuration = (ms: number | null | undefined): string => {
if (ms == null) return '-' if (ms == null) return '-'
if (ms < 1000) return `${ms}ms` if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s` return `${(ms / 1000).toFixed(2)}s`
} }
const copyRequestId = async (requestId: string) => {
try {
await navigator.clipboard.writeText(requestId)
copiedRequestId.value = requestId
appStore.showSuccess(t('admin.usage.requestIdCopied'))
setTimeout(() => { copiedRequestId.value = null }, 2000)
} catch {
appStore.showError(t('common.copyFailed'))
}
}
// Cost tooltip functions // Cost tooltip functions
const showTooltip = (event: MouseEvent, row: UsageLog) => { const showTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement const target = event.currentTarget as HTMLElement

View File

@@ -424,7 +424,8 @@ export default {
billingType: 'Billing', billingType: 'Billing',
balance: 'Balance', balance: 'Balance',
subscription: 'Subscription', subscription: 'Subscription',
imageUnit: ' images' imageUnit: ' images',
userAgent: 'User-Agent'
}, },
// Redeem // Redeem

View File

@@ -421,7 +421,8 @@ export default {
billingType: '消费类型', billingType: '消费类型',
balance: '余额', balance: '余额',
subscription: '订阅', subscription: '订阅',
imageUnit: '张' imageUnit: '张',
userAgent: 'User-Agent'
}, },
// Redeem // Redeem

View File

@@ -576,6 +576,9 @@ export interface UsageLog {
image_count: number image_count: number
image_size: string | null image_size: string | null
// User-Agent
user_agent: string | null
created_at: string created_at: string
user?: User user?: User

View File

@@ -96,7 +96,7 @@ const exportToExcel = async () => {
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'), t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.original'), t('usage.billed'), t('usage.rate'), t('usage.original'), t('usage.billed'),
t('usage.billingType'), t('usage.firstToken'), t('usage.duration'), t('usage.billingType'), t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId') t('admin.usage.requestId'), t('usage.userAgent')
] ]
const rows = all.map(log => [ const rows = all.map(log => [
log.created_at, log.created_at,
@@ -120,7 +120,8 @@ const exportToExcel = async () => {
log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'), log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'),
log.first_token_ms ?? '', log.first_token_ms ?? '',
log.duration_ms, log.duration_ms,
log.request_id || '' log.request_id || '',
log.user_agent || ''
]) ])
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]) const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()

View File

@@ -308,6 +308,11 @@
}}</span> }}</span>
</template> </template>
<template #cell-user_agent="{ row }">
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 max-w-[150px] truncate block" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #empty> <template #empty>
<EmptyState :message="t('usage.noRecords')" /> <EmptyState :message="t('usage.noRecords')" />
</template> </template>
@@ -480,7 +485,8 @@ const columns = computed<Column[]>(() => [
{ key: 'billing_type', label: t('usage.billingType'), sortable: false }, { key: 'billing_type', label: t('usage.billingType'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false }, { key: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false }, { key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true } { key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false }
]) ])
const usageLogs = ref<UsageLog[]>([]) const usageLogs = ref<UsageLog[]>([])
@@ -545,6 +551,19 @@ const formatDuration = (ms: number): string => {
return `${(ms / 1000).toFixed(2)}s` return `${(ms / 1000).toFixed(2)}s`
} }
const formatUserAgent = (ua: string): string => {
// 提取主要客户端标识
if (ua.includes('claude-cli')) return ua.match(/claude-cli\/[\d.]+/)?.[0] || 'Claude CLI'
if (ua.includes('Cursor')) return 'Cursor'
if (ua.includes('VSCode') || ua.includes('vscode')) return 'VS Code'
if (ua.includes('Continue')) return 'Continue'
if (ua.includes('Cline')) return 'Cline'
if (ua.includes('OpenAI')) return 'OpenAI SDK'
if (ua.includes('anthropic')) return 'Anthropic SDK'
// 截断过长的 UA
return ua.length > 30 ? ua.substring(0, 30) + '...' : ua
}
const formatTokens = (value: number): string => { const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) { if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B` return `${(value / 1_000_000_000).toFixed(2)}B`