Files
Aether/frontend/src/features/usage/components/UsageRecordsTable.vue
T

1758 lines
62 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<TableCard
title="使用记录"
class="relative"
>
2025-12-10 20:52:44 +08:00
<template #actions>
<!-- 时间范围筛选 -->
<TimeRangePicker
v-model="timeRangeModel"
:show-granularity="false"
class="hidden shrink-0 md:flex"
/>
2025-12-10 20:52:44 +08:00
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
2025-12-10 20:52:44 +08:00
<!-- 通用搜索 -->
<div class="order-1 flex w-full items-center gap-2 md:order-none md:w-auto">
<div class="relative min-w-0 flex-1 md:w-48 md:flex-none">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="usage-records-search"
v-model="localSearch"
:placeholder="isAdmin ? '搜索用户/密钥' : '搜索密钥/模型'"
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
</div>
<Button
variant="ghost"
size="icon"
data-usage-hide-unknown-toggle="mobile"
class="absolute right-12 top-2.5 h-8 w-8 shrink-0 md:hidden"
:class="hideUnknownRecords ? 'text-primary' : ''"
:title="hideUnknownRecords ? '显示 unknown 请求' : '隐藏 unknown 请求'"
aria-label="隐藏 unknown 模型或提供商的请求"
:aria-pressed="hideUnknownRecords"
@click="$emit('update:hideUnknownRecords', !hideUnknownRecords)"
>
<EyeOff class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="absolute right-4 top-2.5 h-8 w-8 shrink-0 md:hidden"
:class="autoRefresh ? 'text-primary' : ''"
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'"
@click="$emit('update:autoRefresh', !autoRefresh)"
>
<RefreshCcw
class="w-3.5 h-3.5"
:class="autoRefresh ? 'animate-spin' : ''"
/>
</Button>
<div class="order-3 grid w-full grid-cols-2 gap-2 md:hidden">
<!-- 时间范围筛选 -->
<TimeRangePicker
v-model="timeRangeModel"
:show-granularity="false"
class="min-w-0"
preset-trigger-class="!w-full"
/>
2026-05-01 02:14:49 +08:00
<!-- 用户筛选(仅管理员可见) -->
<ServerUserSelector
v-if="isAdmin"
class="min-w-0"
2026-05-01 02:14:49 +08:00
:model-value="filterUser"
:initial-users="availableUsers"
dropdown
2026-05-01 02:14:49 +08:00
@update:model-value="$emit('update:filterUser', $event)"
/>
2025-12-10 20:52:44 +08:00
2026-05-01 02:14:49 +08:00
<!-- 模型筛选 -->
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
>
<SelectTrigger class="h-8 w-full min-w-0 text-xs border-border/60">
2026-05-01 02:14:49 +08:00
<SelectValue placeholder="模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部模型
</SelectItem>
<SelectItem
v-for="model in availableModels"
:key="model"
:value="model"
>
{{ model.replace('claude-', '') }}
</SelectItem>
</SelectContent>
</Select>
2025-12-10 20:52:44 +08:00
2026-05-01 02:14:49 +08:00
<!-- 提供商筛选(仅管理员可见) -->
<Select
v-if="isAdmin"
:model-value="filterProvider"
@update:model-value="$emit('update:filterProvider', $event)"
>
<SelectTrigger class="h-8 w-full min-w-0 text-xs border-border/60">
2026-05-01 02:14:49 +08:00
<SelectValue placeholder="提供商" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部提供商
</SelectItem>
<SelectItem
v-for="provider in availableProviders"
:key="provider"
:value="provider"
>
{{ provider }}
</SelectItem>
</SelectContent>
</Select>
2025-12-10 20:52:44 +08:00
2026-05-01 02:14:49 +08:00
<!-- API格式筛选 -->
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="h-8 w-full min-w-0 text-xs border-border/60">
2026-05-01 02:14:49 +08:00
<SelectValue placeholder="格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部格式
</SelectItem>
<SelectItem
v-for="format in availableApiFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
2026-05-01 02:14:49 +08:00
<!-- 状态筛选 -->
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<SelectTrigger class="h-8 w-full min-w-0 text-xs border-border/60">
2026-05-01 02:14:49 +08:00
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部状态
</SelectItem>
<SelectItem value="stream">
流式
</SelectItem>
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
2025-12-10 20:52:44 +08:00
2026-05-16 15:55:38 +08:00
<!-- 列显示配置(桌面端) -->
<MultiSelect
v-model="visibleColumnIds"
class="hidden md:block"
2026-05-16 15:55:38 +08:00
:options="columnSelectOptions"
placeholder="显示列"
trigger-class="w-40 h-8 text-xs border-border/60"
2026-05-16 15:55:38 +08:00
dropdown-min-width="14rem"
:searchable="false"
/>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 自动刷新按钮 -->
<Button
variant="ghost"
size="icon"
data-usage-hide-unknown-toggle="desktop"
class="hidden h-8 w-8 shrink-0 md:inline-flex"
:class="hideUnknownRecords ? 'text-primary' : ''"
:title="hideUnknownRecords ? '显示 unknown 请求' : '隐藏 unknown 请求'"
aria-label="隐藏 unknown 模型或提供商的请求"
:aria-pressed="hideUnknownRecords"
@click="$emit('update:hideUnknownRecords', !hideUnknownRecords)"
>
<EyeOff class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="hidden h-8 w-8 shrink-0 md:inline-flex"
:class="autoRefresh ? 'text-primary' : ''"
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'"
@click="$emit('update:autoRefresh', !autoRefresh)"
>
<RefreshCcw
class="w-3.5 h-3.5"
:class="autoRefresh ? 'animate-spin' : ''"
/>
</Button>
2025-12-10 20:52:44 +08:00
</template>
<!-- 移动端卡片视图 -->
<div class="md:hidden">
<div
v-if="records.length === 0"
class="text-center py-12 text-muted-foreground"
>
暂无请求记录
</div>
<div
v-for="record in records"
v-else
:key="record.id"
class="border-b border-border/40 px-3 py-2.5"
:class="isAdmin ? 'cursor-pointer active:bg-muted/30 transition-colors' : ''"
@click="isAdmin && emit('showDetail', record.id)"
>
<!-- 第一行:模型 + 费用 -->
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
<span class="min-w-0 truncate text-[15px] font-semibold leading-5">{{ record.model }}</span>
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
:title="getReasoningEffortTitle(record)"
>
{{ getReasoningEffort(record) }}
</Badge>
<Badge
v-if="getServiceTierBadge(record)"
variant="outline"
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
:class="getServiceTierBadge(record)?.className"
:title="getServiceTierBadge(record)?.title"
:aria-label="getServiceTierBadge(record)?.ariaLabel"
>
{{ getServiceTierBadge(record)?.label }}
</Badge>
<!-- 状态 Badge -->
<Badge
v-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
失败
</Badge>
<Badge
v-else-if="getDisplayStatus(record) === 'pending'"
variant="outline"
class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
等待
</Badge>
<Badge
v-else-if="getDisplayStatus(record) === 'streaming'"
variant="outline"
class="whitespace-nowrap animate-pulse border-primary/50 text-primary text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
传输
</Badge>
<Badge
v-else-if="record.status === 'cancelled'"
variant="outline"
class="whitespace-nowrap border-amber-500/50 text-amber-600 dark:text-amber-400 text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
取消
</Badge>
<Badge
v-else-if="getStreamModeSegments(record).hasConversion"
:variant="streamBadgeVariant(getStreamModeSegments(record).client === '流式')"
:class="(streamBadgeVariant(getStreamModeSegments(record).client === '流式') === 'secondary')
? 'whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center gap-0.5 flex-shrink-0'
: 'whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center gap-0.5 flex-shrink-0'"
>
<span>{{ getStreamModeSegments(record).client }}</span>
<span class="opacity-60">→</span>
<span>{{ getStreamModeSegments(record).upstream }}</span>
</Badge>
<Badge
v-else
:variant="streamBadgeVariant(getUpstreamStream(record))"
:class="(streamBadgeVariant(getUpstreamStream(record)) === 'secondary')
? 'whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0'
: 'whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0'"
>
{{ getStreamModeLabel(record) }}
</Badge>
</div>
<span
v-if="getActualModel(record)"
class="text-[11px] text-muted-foreground truncate block"
>-> {{ getActualModel(record) }}</span>
</div>
<div class="flex flex-col items-end flex-shrink-0">
<span class="text-sm text-primary font-semibold leading-5">{{ formatCurrency(record.cost || 0) }}</span>
<span
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-[10px] text-muted-foreground"
>{{ formatCurrency(record.actual_cost) }}</span>
</div>
</div>
<!-- 第二行:时间 + API格式 -->
<div class="mt-1.5 flex min-w-0 items-center gap-1.5 text-[10px] leading-3.5 text-muted-foreground">
<span class="shrink-0 tabular-nums text-foreground whitespace-nowrap">
{{ formatRecordTime(record.created_at) }}
</span>
<span class="shrink-0 tabular-nums whitespace-nowrap">
{{ formatRecordShortDate(record.created_at) }}
</span>
<template v-if="record.api_format">
<span class="text-muted-foreground/40">·</span>
<span class="min-w-0 truncate">{{ formatApiFormat(record.api_format) }}</span>
</template>
</div>
2026-06-01 21:13:10 +08:00
<!-- 第三行:用户 + 提供商 -->
<div
v-if="isAdmin"
2026-06-01 21:59:45 +08:00
class="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] leading-3.5 text-muted-foreground"
2026-06-01 21:13:10 +08:00
>
<span
2026-06-01 21:59:45 +08:00
class="min-w-0 truncate"
2026-06-01 21:13:10 +08:00
:title="formatRecordUserProviderLine(record)"
>
2026-06-01 21:59:45 +08:00
{{ formatRecordUserSegment(record) }}
2026-06-01 21:13:10 +08:00
</span>
2026-06-01 21:59:45 +08:00
<span class="shrink-0 text-muted-foreground/40">·</span>
<span class="min-w-0 truncate">{{ formatRecordProviderSegment(record) }}</span>
2026-06-01 21:13:10 +08:00
</div>
<!-- 第四行:性能指标 -->
<div class="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[10px] leading-3.5 text-muted-foreground">
<span
class="min-w-0 truncate whitespace-nowrap tabular-nums text-foreground"
:title="getRecordPerformanceTitle(record)"
>
<span class="text-muted-foreground">耗时&amp;速度</span>
<template v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'">
<span class="ml-1">{{ formatRecordDurationSeconds(record.first_byte_time_ms) }}</span>
<span class="text-muted-foreground"> / </span>
<ElapsedTimeText
class="text-primary"
:created-at="record.created_at"
2026-06-10 09:16:15 +08:00
:response-time-updated-at="record.response_time_updated_at ?? null"
:status="getDisplayStatus(record)"
:response-time-ms="record.response_time_ms ?? null"
/>
<span class="text-muted-foreground"> / </span>
<span>{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
</template>
<span
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="ml-1"
>{{ formatRecordLatencyPair(record) }} / {{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
<span
v-else
class="ml-1"
>- / {{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
</span>
<span class="text-muted-foreground/40">·</span>
<span
class="min-w-0 truncate whitespace-nowrap tabular-nums text-foreground"
:title="hasRecordCacheTokens(record) ? getRecordCacheTokensTitle(record) : undefined"
>
<span class="text-muted-foreground">Tokens</span>
<span class="ml-1">{{ formatTokens(getRecordEffectiveInputTokens(record)) }} / {{ formatTokens(record.output_tokens || 0) }}</span>
<template v-if="hasRecordCacheTokens(record)">
<span class="text-muted-foreground"> | </span>
<span>{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }} / {{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}</span>
</template>
</span>
</div>
</div>
</div>
<!-- 桌面端表格视图 -->
2026-05-18 12:41:57 +08:00
<Table
class="hidden md:table table-fixed w-full"
:class="[desktopTableMinWidthClass]"
2026-05-18 12:41:57 +08:00
>
2026-05-01 02:14:49 +08:00
<colgroup v-if="isAdmin">
2026-05-28 12:11:43 +08:00
<col
v-if="isColumnVisible('time')"
class="w-[8%]"
>
<col
v-if="isColumnVisible('user')"
class="w-[12%]"
>
<col
v-if="isColumnVisible('model')"
class="w-[14%]"
>
<col
v-if="isColumnVisible('provider')"
class="w-[16%]"
>
<col
v-if="isColumnVisible('api_format')"
class="w-[15%]"
>
<col
v-if="isColumnVisible('status')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('tokens')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('cost')"
class="w-[6%]"
>
<col
v-if="isColumnVisible('performance')"
class="w-[9%]"
>
<col
v-if="isColumnVisible('client_family')"
class="w-[12%]"
>
<col
v-if="isColumnVisible('client_ip')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('user_agent')"
class="w-[13%]"
>
2026-05-01 02:14:49 +08:00
</colgroup>
<colgroup v-else>
2026-05-28 12:11:43 +08:00
<col
v-if="isColumnVisible('time')"
class="w-[9%]"
>
<col
v-if="isColumnVisible('key')"
class="w-[17%]"
>
<col
v-if="isColumnVisible('model')"
class="w-[22%]"
>
<col
v-if="isColumnVisible('api_format')"
class="w-[14%]"
>
<col
v-if="isColumnVisible('status')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('tokens')"
class="w-[11%]"
>
<col
v-if="isColumnVisible('cost')"
class="w-[7%]"
>
<col
v-if="isColumnVisible('performance')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('client_family')"
class="w-[12%]"
>
<col
v-if="isColumnVisible('client_ip')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('user_agent')"
class="w-[13%]"
>
2026-05-01 02:14:49 +08:00
</colgroup>
2025-12-10 20:52:44 +08:00
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
2026-05-28 12:11:43 +08:00
<TableHead
v-if="isColumnVisible('time')"
class="h-12 font-semibold w-[8%]"
>
时间
</TableHead>
2026-05-01 02:14:49 +08:00
<SortableTableHead
2026-05-16 15:55:38 +08:00
v-if="isAdmin && isColumnVisible('user')"
2026-05-05 18:27:36 +08:00
class="h-12 font-semibold w-[12%]"
2026-05-01 02:14:49 +08:00
column-key="user"
:sortable="false"
:filter-active="filterUser !== '__all__'"
filter-title="筛选用户"
filter-content-class="w-64 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
用户
2026-05-01 02:14:49 +08:00
<template #filter="{ close }">
<ServerUserSelector
2026-05-01 02:14:49 +08:00
:model-value="filterUser"
:initial-users="availableUsers"
2026-05-01 02:14:49 +08:00
@update:model-value="$emit('update:filterUser', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead
2026-05-16 15:55:38 +08:00
v-if="!isAdmin && isColumnVisible('key')"
2026-05-05 18:27:36 +08:00
class="h-12 font-semibold w-[17%]"
>
密钥
</TableHead>
2026-05-01 02:14:49 +08:00
<SortableTableHead
2026-05-16 15:55:38 +08:00
v-if="isColumnVisible('model')"
2026-05-18 12:41:57 +08:00
class="h-12 font-semibold"
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
2026-05-01 02:14:49 +08:00
column-key="model"
:sortable="false"
:filter-active="filterModel !== '__all__'"
filter-title="筛选模型"
filter-content-class="w-64 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
模型
2026-05-01 02:14:49 +08:00
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterModel"
:options="modelFilterOptions"
@update:model-value="$emit('update:filterModel', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
2026-05-16 15:55:38 +08:00
v-if="isAdmin && isColumnVisible('provider')"
2026-05-06 16:20:09 +08:00
class="h-12 font-semibold w-[16%]"
2026-05-01 02:14:49 +08:00
column-key="provider"
:sortable="false"
:filter-active="filterProvider !== '__all__'"
filter-title="筛选提供商"
filter-content-class="w-48 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
提供商
2026-05-01 02:14:49 +08:00
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterProvider"
:options="providerFilterOptions"
@update:model-value="$emit('update:filterProvider', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
2026-05-16 15:55:38 +08:00
v-if="isColumnVisible('api_format')"
2026-05-18 12:41:57 +08:00
class="h-12 font-semibold"
:class="[isAdmin ? 'w-[15%]' : 'w-[14%]']"
2026-05-01 02:14:49 +08:00
column-key="api_format"
:sortable="false"
:filter-active="filterApiFormat !== '__all__'"
filter-title="筛选 API 格式"
filter-content-class="w-72 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
API格式
2026-05-01 02:14:49 +08:00
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterApiFormat"
:options="apiFormatFilterOptions"
@update:model-value="$emit('update:filterApiFormat', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
2026-05-16 15:55:38 +08:00
v-if="isColumnVisible('status')"
2026-05-07 03:23:20 +08:00
class="h-12 font-semibold w-[10%] text-center"
2026-05-01 02:14:49 +08:00
column-key="status"
:sortable="false"
align="center"
:filter-active="filterStatus !== '__all__'"
filter-title="筛选类型"
filter-content-class="w-44 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
类型
2026-05-01 02:14:49 +08:00
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterStatus"
:options="statusFilterOptions"
@update:model-value="$emit('update:filterStatus', $event)"
@select="close"
/>
</template>
</SortableTableHead>
2026-05-28 12:11:43 +08:00
<TableHead
v-if="isColumnVisible('tokens')"
class="h-12 font-semibold w-[10%] text-center"
>
Tokens
</TableHead>
2026-05-28 12:11:43 +08:00
<TableHead
v-if="isColumnVisible('cost')"
class="h-12 font-semibold w-[6%] text-right"
>
费用
</TableHead>
2026-05-28 12:11:43 +08:00
<TableHead
v-if="isColumnVisible('performance')"
class="h-12 font-semibold w-[9%] text-right"
>
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="whitespace-nowrap">首字/总耗时</span>
<span class="text-muted-foreground font-normal">输出速度</span>
</div>
2025-12-10 20:52:44 +08:00
</TableHead>
2026-05-16 15:55:38 +08:00
<SortableTableHead
v-if="isColumnVisible('client_family')"
2026-05-17 23:05:02 +08:00
class="h-12 font-semibold w-[12%]"
2026-05-16 15:55:38 +08:00
column-key="client_family"
:sortable="false"
:filter-active="filterClientFamily !== '__all__'"
filter-title="筛选客户端"
filter-content-class="w-44 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
客户端
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterClientFamily"
:options="clientFamilyFilterOptions"
@update:model-value="$emit('update:filterClientFamily', $event)"
@select="close"
/>
</template>
</SortableTableHead>
2026-05-28 12:11:43 +08:00
<TableHead
v-if="isColumnVisible('client_ip')"
class="h-12 font-semibold w-[10%]"
>
2026-05-16 15:55:38 +08:00
IP 地址
</TableHead>
2026-05-28 12:11:43 +08:00
<TableHead
v-if="isColumnVisible('user_agent')"
class="h-12 font-semibold w-[13%]"
>
2026-05-16 15:55:38 +08:00
User-Agent
</TableHead>
2025-12-10 20:52:44 +08:00
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="records.length === 0">
<TableCell
2026-05-16 15:55:38 +08:00
:colspan="visibleColumnCount"
class="text-center py-12 text-muted-foreground"
>
2025-12-10 20:52:44 +08:00
暂无请求记录
</TableCell>
</TableRow>
<TableRow
v-for="record in records"
v-else
2025-12-10 20:52:44 +08:00
:key="record.id"
:class="isAdmin ? 'cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]' : 'border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]'"
@mousedown="handleRowMouseDown($event, record.id)"
2025-12-10 20:52:44 +08:00
@click="handleRowClick($event, record.id)"
>
2026-05-28 12:11:43 +08:00
<TableCell
v-if="isColumnVisible('time')"
class="py-4 w-[8%] align-top"
>
2026-05-05 18:27:36 +08:00
<div class="flex flex-col gap-0.5 leading-tight">
<span class="text-xs text-foreground tabular-nums whitespace-nowrap">
{{ formatRecordTime(record.created_at) }}
</span>
<span class="text-[11px] text-muted-foreground tabular-nums whitespace-nowrap">
{{ formatRecordDate(record.created_at) }}
</span>
</div>
2025-12-10 20:52:44 +08:00
</TableCell>
<TableCell
2026-05-16 15:55:38 +08:00
v-if="isAdmin && isColumnVisible('user')"
2026-05-05 18:27:36 +08:00
class="py-4 w-[12%] truncate"
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
>
<div class="flex flex-col text-xs gap-0.5">
<span class="truncate">
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
</span>
<span
v-if="record.api_key?.name"
class="text-muted-foreground truncate"
:title="record.api_key.name"
>
{{ record.api_key.name }}
</span>
</div>
2025-12-10 20:52:44 +08:00
</TableCell>
<!-- 用户页面的密钥列 -->
<TableCell
2026-05-16 15:55:38 +08:00
v-if="!isAdmin && isColumnVisible('key')"
2026-05-05 18:27:36 +08:00
class="py-4 w-[17%]"
:title="record.api_key?.name || '-'"
>
<div class="flex flex-col text-xs gap-0.5">
<span class="truncate">{{ record.api_key?.name || '-' }}</span>
<span
v-if="record.api_key?.display"
class="text-muted-foreground truncate"
>
{{ record.api_key.display }}
</span>
</div>
</TableCell>
<TableCell
2026-05-16 15:55:38 +08:00
v-if="isColumnVisible('model')"
2026-05-18 12:41:57 +08:00
class="font-medium py-4"
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
:title="getModelTooltip(record)"
>
<div
v-if="getActualModel(record)"
class="flex flex-col text-xs gap-0.5"
>
<div class="flex min-w-0 items-center gap-1">
2025-12-10 20:52:44 +08:00
<span class="truncate">{{ record.model }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 text-muted-foreground flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
clip-rule="evenodd"
/>
2025-12-10 20:52:44 +08:00
</svg>
2026-05-27 17:00:09 +08:00
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
:title="getReasoningEffortTitle(record)"
>
{{ getReasoningEffort(record) }}
</Badge>
<Badge
v-if="getServiceTierBadge(record)"
2026-05-27 17:00:09 +08:00
variant="outline"
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
:class="getServiceTierBadge(record)?.className"
:title="getServiceTierBadge(record)?.title"
:aria-label="getServiceTierBadge(record)?.ariaLabel"
2026-05-27 17:00:09 +08:00
>
{{ getServiceTierBadge(record)?.label }}
2026-05-27 17:00:09 +08:00
</Badge>
2025-12-10 20:52:44 +08:00
</div>
<span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span>
</div>
<span
v-else
class="flex min-w-0 items-center gap-1"
>
<span class="truncate">{{ record.model }}</span>
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
class="h-4 rounded-full border-primary/30 bg-primary/5 px-1.5 text-[10px] leading-4 text-primary flex-shrink-0"
:title="getReasoningEffortTitle(record)"
>
{{ getReasoningEffort(record) }}
</Badge>
<Badge
v-if="getServiceTierBadge(record)"
variant="outline"
class="h-4 whitespace-nowrap rounded-full px-1.5 text-[10px] leading-4 flex-shrink-0"
:class="getServiceTierBadge(record)?.className"
:title="getServiceTierBadge(record)?.title"
:aria-label="getServiceTierBadge(record)?.ariaLabel"
>
{{ getServiceTierBadge(record)?.label }}
</Badge>
</span>
2025-12-10 20:52:44 +08:00
</TableCell>
<TableCell
2026-05-16 15:55:38 +08:00
v-if="isAdmin && isColumnVisible('provider')"
2026-05-06 16:20:09 +08:00
class="py-4 w-[16%]"
>
2026-05-05 18:27:36 +08:00
<div class="flex min-w-0 items-center gap-1">
<div class="flex min-w-0 flex-col text-xs gap-0.5">
2026-05-19 17:23:57 +08:00
<span class="truncate">{{ record.provider }}</span>
2025-12-10 20:52:44 +08:00
<span
2026-05-19 17:23:57 +08:00
v-if="record.provider_key_name"
class="text-muted-foreground truncate"
2026-05-19 17:23:57 +08:00
:title="record.provider_key_name"
2025-12-10 20:52:44 +08:00
>
2026-05-19 17:23:57 +08:00
{{ record.provider_key_name }}
<span
2026-05-19 17:23:57 +08:00
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-foreground/60"
>({{ record.rate_multiplier }}x)</span>
2025-12-10 20:52:44 +08:00
</span>
</div>
2026-05-23 17:54:48 +08:00
<Shuffle
v-if="record.has_fallback"
2026-05-23 17:54:48 +08:00
data-usage-attempt-marker="fallback"
class="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0"
title="此请求发生了 Provider 故障转移"
2026-05-23 17:54:48 +08:00
aria-label="发生 Provider 故障转移"
/>
<RefreshCcw
v-if="record.has_retry"
data-usage-attempt-marker="retry"
class="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 flex-shrink-0"
2026-05-23 17:54:48 +08:00
title="此请求发生了重试"
aria-label="发生重试"
/>
2025-12-10 20:52:44 +08:00
</div>
</TableCell>
<TableCell
2026-05-16 15:55:38 +08:00
v-if="isColumnVisible('api_format')"
2026-05-18 12:41:57 +08:00
class="py-4"
:class="[isAdmin ? 'w-[15%]' : 'w-[14%]']"
:title="getApiFormatTooltip(record)"
>
<!-- 有格式转换或同族格式差异:两行显示 -->
<div
v-if="shouldShowFormatConversion(record)"
class="flex flex-col text-xs gap-0.5"
>
<div class="flex items-center gap-1 whitespace-nowrap">
<span>{{ formatApiFormat(record.api_format!) }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 text-muted-foreground flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
clip-rule="evenodd"
/>
</svg>
</div>
<span class="text-muted-foreground whitespace-nowrap">{{ formatApiFormat(record.endpoint_api_format!) }}</span>
</div>
<!-- 无格式转换:单行显示 -->
<span
v-else-if="record.api_format"
class="text-xs whitespace-nowrap"
>{{ formatApiFormat(record.api_format) }}</span>
<span
v-else
class="text-muted-foreground text-xs"
>-</span>
2025-12-10 20:52:44 +08:00
</TableCell>
2026-05-28 12:11:43 +08:00
<TableCell
v-if="isColumnVisible('status')"
class="text-center py-4 w-[10%]"
>
<!-- 优先显示请求状态 -->
<Badge
v-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap"
>
失败
</Badge>
<Badge
v-else-if="getDisplayStatus(record) === 'pending'"
variant="outline"
class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground"
>
等待中
</Badge>
<Badge
v-else-if="getDisplayStatus(record) === 'streaming'"
variant="outline"
class="whitespace-nowrap animate-pulse border-primary/50 text-primary"
>
传输中
</Badge>
<Badge
v-else-if="record.status === 'cancelled'"
variant="outline"
class="whitespace-nowrap border-amber-500/50 text-amber-600 dark:text-amber-400"
>
已取消
</Badge>
<Badge
v-else-if="getStreamModeSegments(record).hasConversion"
:variant="streamBadgeVariant(getStreamModeSegments(record).client === '流式')"
:class="(streamBadgeVariant(getStreamModeSegments(record).client === '流式') === 'secondary')
? 'whitespace-nowrap inline-flex items-center gap-1'
: 'whitespace-nowrap border-border/60 text-muted-foreground inline-flex items-center gap-1'"
>
<span>{{ getStreamModeSegments(record).client }}</span>
<span class="opacity-60">→</span>
<span>{{ getStreamModeSegments(record).upstream }}</span>
</Badge>
<Badge
v-else
:variant="streamBadgeVariant(getUpstreamStream(record))"
:class="(streamBadgeVariant(getUpstreamStream(record)) === 'secondary')
? 'whitespace-nowrap'
: 'whitespace-nowrap border-border/60 text-muted-foreground'"
>
{{ getStreamModeLabel(record) }}
</Badge>
</TableCell>
2026-05-28 12:11:43 +08:00
<TableCell
v-if="isColumnVisible('tokens')"
class="py-4 w-[10%]"
>
2026-05-05 18:27:36 +08:00
<div class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums">
<span class="justify-self-end whitespace-nowrap text-right">
{{ formatTokens(getRecordEffectiveInputTokens(record)) }}
</span>
<span class="justify-self-center text-muted-foreground">
/
</span>
<span class="justify-self-start whitespace-nowrap text-left">
{{ formatTokens(record.output_tokens || 0) }}
</span>
</div>
<div class="mt-0.5 grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums text-muted-foreground">
<span
2026-05-18 12:41:57 +08:00
class="justify-self-end whitespace-nowrap text-right"
2026-05-05 18:27:36 +08:00
:class="[
hasPositiveTokens(getRecordCacheReadTokens(record)) ? 'text-foreground/70' : ''
]"
>
{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }}
</span>
<span class="justify-self-center">
/
</span>
<span
2026-05-18 12:41:57 +08:00
class="justify-self-start whitespace-nowrap text-left"
2026-05-05 18:27:36 +08:00
:class="[
hasPositiveTokens(getRecordCacheCreationTokens(record)) ? 'text-foreground/70' : ''
]"
>
{{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}
</span>
2025-12-10 20:52:44 +08:00
</div>
</TableCell>
2026-05-28 12:11:43 +08:00
<TableCell
v-if="isColumnVisible('cost')"
class="text-right py-4 w-[6%]"
>
2025-12-10 20:52:44 +08:00
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-muted-foreground"
>
2025-12-10 20:52:44 +08:00
{{ formatCurrency(record.actual_cost) }}
</span>
</div>
</TableCell>
2026-05-28 12:11:43 +08:00
<TableCell
v-if="isColumnVisible('performance')"
class="text-right py-4 w-[9%]"
>
<!-- pending/streaming 状态:首字与动态总耗时保留在同一行 -->
<div
v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
class="flex flex-col items-end text-xs gap-0.5"
2025-12-10 20:52:44 +08:00
>
<span class="tabular-nums whitespace-nowrap">
<span>{{ formatRecordDurationSeconds(record.first_byte_time_ms) }}</span>
<span class="text-muted-foreground"> / </span>
<ElapsedTimeText
class="text-primary"
:created-at="record.created_at"
2026-06-10 09:16:15 +08:00
:response-time-updated-at="record.response_time_updated_at ?? null"
:status="getDisplayStatus(record)"
:response-time-ms="record.response_time_ms ?? null"
/>
</span>
</div>
<!-- 已完成状态:首字 + 总耗时 -->
<div
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="flex flex-col items-end text-xs gap-0.5"
:title="getRecordPerformanceTitle(record)"
>
<span class="tabular-nums whitespace-nowrap">{{ formatRecordLatencyPair(record) }}</span>
<span class="text-muted-foreground tabular-nums whitespace-nowrap">
{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}
</span>
</div>
<span
v-else
class="text-muted-foreground"
>-</span>
2025-12-10 20:52:44 +08:00
</TableCell>
2026-05-16 15:55:38 +08:00
<TableCell
v-if="isColumnVisible('client_family')"
2026-05-17 23:05:02 +08:00
class="py-4 w-[12%] text-xs"
:title="formatClientFamily(record.client_family)"
2026-05-16 15:55:38 +08:00
>
<Badge
variant="outline"
class="w-fit max-w-full border-border/60 text-muted-foreground"
>
<span class="truncate">{{ formatClientFamily(record.client_family) }}</span>
</Badge>
2026-05-16 15:55:38 +08:00
</TableCell>
<TableCell
v-if="isColumnVisible('client_ip')"
class="py-4 w-[10%] text-xs truncate"
:title="record.client_ip || '-'"
>
{{ record.client_ip || '-' }}
</TableCell>
<TableCell
v-if="isColumnVisible('user_agent')"
class="py-4 w-[13%] text-xs truncate"
:title="record.user_agent || '-'"
>
{{ formatUserAgent(record.user_agent) }}
</TableCell>
2025-12-10 20:52:44 +08:00
</TableRow>
</TableBody>
</Table>
<!-- 分页控件 -->
<template #pagination>
<Pagination
v-if="totalRecords > 0"
:current="currentPage"
:total="totalRecords"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
cache-key="usage-records-page-size"
2025-12-10 20:52:44 +08:00
@update:current="$emit('update:currentPage', $event)"
@update:page-size="$emit('update:pageSize', $event)"
/>
</template>
</TableCard>
</template>
<script setup lang="ts">
import { ref, computed, onBeforeUnmount, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import {
TableCard,
Badge,
Button,
Input,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Pagination,
2026-05-01 02:14:49 +08:00
SortableTableHead,
TableFilterMenu,
} from '@/components/ui'
import { EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
2025-12-10 20:52:44 +08:00
import { formatTokens, formatCurrency } from '@/utils/format'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
formatOutputRate,
formatOutputRateValue,
getDisplayOutputRate,
getGenerationTimeMs,
} from '../performance'
import {
formatUsageStreamLabel,
isUsageRecordFailed,
isUsageUpstreamStream,
resolveDisplayRequestStatus,
resolveUsageStreamLabelSegments
} from '../utils/status'
2025-12-10 20:52:44 +08:00
import { useRowClick } from '@/composables/useRowClick'
import { useDarkMode } from '@/composables/useDarkMode'
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
import type { DateRangeParams, UsageRecord } from '../types'
2026-05-16 15:55:38 +08:00
import { MultiSelect, TimeRangePicker } from '@/components/common'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
import ElapsedTimeText from './ElapsedTimeText.vue'
import ServerUserSelector from './ServerUserSelector.vue'
2025-12-10 20:52:44 +08:00
export interface UserOption {
id: string
username: string
email: string
}
2026-05-01 02:14:49 +08:00
interface FilterOption {
value: string
label: string
disabled?: boolean
}
2026-05-16 15:55:38 +08:00
type UsageRecordColumnId =
| 'time'
| 'user'
| 'key'
| 'model'
| 'provider'
| 'api_format'
| 'status'
| 'tokens'
| 'cost'
| 'performance'
| 'client_family'
| 'client_ip'
| 'user_agent'
interface UsageRecordColumnOption {
id: UsageRecordColumnId
label: string
adminOnly?: boolean
userOnly?: boolean
}
2025-12-10 20:52:44 +08:00
const props = defineProps<{
records: UsageRecord[]
isAdmin: boolean
showActualCost: boolean
loading: boolean
// 时间范围
timeRange: DateRangeParams
2025-12-10 20:52:44 +08:00
// 筛选
filterSearch: string
2025-12-10 20:52:44 +08:00
filterUser: string
filterModel: string
filterProvider: string
filterApiFormat: string
2025-12-10 20:52:44 +08:00
filterStatus: string
2026-05-16 15:55:38 +08:00
filterClientFamily: string
2025-12-10 20:52:44 +08:00
availableUsers: UserOption[]
availableModels: string[]
availableProviders: string[]
2026-05-16 15:55:38 +08:00
availableClientFamilies: string[]
2025-12-10 20:52:44 +08:00
// 分页
currentPage: number
pageSize: number
totalRecords: number
pageSizeOptions: number[]
2025-12-19 17:31:15 +08:00
// 自动刷新
autoRefresh: boolean
hideUnknownRecords: boolean
2025-12-10 20:52:44 +08:00
}>()
const emit = defineEmits<{
'update:timeRange': [value: DateRangeParams]
'update:filterSearch': [value: string]
2025-12-10 20:52:44 +08:00
'update:filterUser': [value: string]
'update:filterModel': [value: string]
'update:filterProvider': [value: string]
'update:filterApiFormat': [value: string]
2025-12-10 20:52:44 +08:00
'update:filterStatus': [value: string]
2026-05-16 15:55:38 +08:00
'update:filterClientFamily': [value: string]
2025-12-10 20:52:44 +08:00
'update:currentPage': [value: number]
'update:pageSize': [value: number]
2025-12-19 17:31:15 +08:00
'update:autoRefresh': [value: boolean]
'update:hideUnknownRecords': [value: boolean]
2025-12-10 20:52:44 +08:00
'refresh': []
'showDetail': [id: string]
'prefetchDetail': [id: string]
2025-12-10 20:52:44 +08:00
}>()
2026-05-28 12:11:43 +08:00
const USAGE_RECORD_COLUMN_OPTIONS: UsageRecordColumnOption[] = [
{ id: 'time', label: '时间' },
{ id: 'user', label: '用户', adminOnly: true },
{ id: 'key', label: '密钥', userOnly: true },
{ id: 'model', label: '模型' },
{ id: 'provider', label: '提供商', adminOnly: true },
{ id: 'api_format', label: 'API格式' },
{ id: 'status', label: '类型/状态' },
{ id: 'tokens', label: 'Tokens' },
{ id: 'cost', label: '费用' },
{ id: 'performance', label: '耗时/速度' },
{ id: 'client_family', label: '客户端类型' },
{ id: 'client_ip', label: 'IP 地址' },
{ id: 'user_agent', label: 'User-Agent' },
]
const DEFAULT_ADMIN_COLUMNS: UsageRecordColumnId[] = [
'time',
'user',
'model',
'provider',
'api_format',
'status',
'tokens',
'cost',
'performance',
]
const DEFAULT_USER_COLUMNS: UsageRecordColumnId[] = [
'time',
'key',
'model',
'api_format',
'status',
'tokens',
'cost',
'performance',
]
// 使用统一 API 格式枚举,避免使用记录筛选项和系统格式列表漂移。
const availableApiFormats = API_FORMAT_ORDER.map((value) => ({
value,
label: formatApiFormat(value),
}))
2026-05-16 15:55:38 +08:00
const adminVisibleColumnIds = useLocalStorage<UsageRecordColumnId[]>(
'usage-records-visible-columns-admin',
DEFAULT_ADMIN_COLUMNS,
)
const userVisibleColumnIds = useLocalStorage<UsageRecordColumnId[]>(
'usage-records-visible-columns-user',
DEFAULT_USER_COLUMNS,
)
const roleColumnOptions = computed(() => USAGE_RECORD_COLUMN_OPTIONS.filter((column) => {
if (column.adminOnly && !props.isAdmin) return false
if (column.userOnly && props.isAdmin) return false
return true
}))
const roleColumnIds = computed(() => new Set(roleColumnOptions.value.map(column => column.id)))
function sanitizeColumnIds(
ids: readonly string[],
fallback: readonly UsageRecordColumnId[],
): UsageRecordColumnId[] {
const seen = new Set<UsageRecordColumnId>()
const sanitized = ids.filter((id): id is UsageRecordColumnId => {
if (!roleColumnIds.value.has(id as UsageRecordColumnId)) return false
if (seen.has(id as UsageRecordColumnId)) return false
seen.add(id as UsageRecordColumnId)
return true
})
return sanitized.length > 0 ? sanitized : [...fallback]
}
const visibleColumnIds = computed<UsageRecordColumnId[]>({
get: () => sanitizeColumnIds(
props.isAdmin ? adminVisibleColumnIds.value : userVisibleColumnIds.value,
props.isAdmin ? DEFAULT_ADMIN_COLUMNS : DEFAULT_USER_COLUMNS,
),
set: (value) => {
const sanitized = sanitizeColumnIds(value, props.isAdmin ? DEFAULT_ADMIN_COLUMNS : DEFAULT_USER_COLUMNS)
if (props.isAdmin) {
adminVisibleColumnIds.value = sanitized
} else {
userVisibleColumnIds.value = sanitized
}
},
})
const visibleColumnSet = computed(() => new Set<UsageRecordColumnId>(visibleColumnIds.value))
const visibleColumnCount = computed(() => visibleColumnIds.value.length)
const desktopTableMinWidthClass = computed(() => {
const metadataColumnCount = visibleColumnIds.value.filter(column => (
column === 'client_family' ||
column === 'client_ip' ||
2026-05-17 23:05:02 +08:00
column === 'user_agent'
2026-05-16 15:55:38 +08:00
)).length
if (metadataColumnCount >= 3) return 'min-w-[1520px]'
if (metadataColumnCount > 0) return 'min-w-[1320px]'
return props.isAdmin ? 'min-w-[1120px]' : 'min-w-[960px]'
})
const columnSelectOptions = computed<MultiSelectOption[]>(() => roleColumnOptions.value.map(column => ({
value: column.id,
label: column.label,
})))
function isColumnVisible(column: UsageRecordColumnId): boolean {
return visibleColumnSet.value.has(column)
}
2026-05-01 02:14:49 +08:00
const modelFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部模型' },
...props.availableModels.map((model) => ({
value: model,
label: model.replace('claude-', ''),
})),
])
const providerFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部提供商' },
...props.availableProviders.map((provider) => ({
value: provider,
label: provider,
})),
])
2026-05-16 15:55:38 +08:00
const clientFamilyFilterOptions = computed<FilterOption[]>(() => {
const families = new Set<string>(props.availableClientFamilies)
props.records.forEach((record) => {
const family = record.client_family?.trim()
if (family) families.add(family)
})
return [
{ value: '__all__', label: '全部客户端' },
...Array.from(families).sort().map((family) => ({
value: family,
label: formatClientFamily(family),
})),
]
})
2026-05-01 02:14:49 +08:00
const apiFormatFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部格式' },
...availableApiFormats.map((format) => ({
value: format.value,
label: format.label,
})),
])
const statusFilterOptions: FilterOption[] = [
{ value: '__all__', label: '全部状态' },
{ value: 'stream', label: '流式' },
{ value: 'standard', label: '标准' },
{ value: 'active', label: '活跃' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
{ value: 'has_retry', label: '发生重试' },
{ value: 'has_fallback', label: '发生转移' },
]
const timeRangeModel = computed({
get: () => props.timeRange,
set: (value: DateRangeParams) => emit('update:timeRange', value)
})
// 通用搜索(输入防抖)
const SEARCH_EMIT_DEBOUNCE_MS = 300
const localSearch = ref(props.filterSearch)
let searchEmitTimer: ReturnType<typeof setTimeout> | null = null
function cancelPendingSearchEmit() {
if (searchEmitTimer !== null) {
clearTimeout(searchEmitTimer)
searchEmitTimer = null
}
}
function scheduleSearchEmit(value: string) {
cancelPendingSearchEmit()
searchEmitTimer = setTimeout(() => {
searchEmitTimer = null
if (value !== props.filterSearch) {
emit('update:filterSearch', value)
}
}, SEARCH_EMIT_DEBOUNCE_MS)
}
function getDisplayStatus(record: UsageRecord) {
return resolveDisplayRequestStatus(record)
}
function getStreamModeLabel(record: UsageRecord): string {
return formatUsageStreamLabel(record)
}
function getStreamModeSegments(record: UsageRecord) {
return resolveUsageStreamLabelSegments(record)
}
function getUpstreamStream(record: UsageRecord): boolean {
return isUsageUpstreamStream(record)
}
2026-05-05 18:27:36 +08:00
function parseRecordDateTime(dateStr: string): Date {
const utcDateStr = dateStr.includes('Z') || dateStr.includes('+') ? dateStr : `${dateStr}Z`
return new Date(utcDateStr)
}
function formatRecordDate(dateStr: string): string {
const date = parseRecordDateTime(dateStr)
const year = String(date.getFullYear())
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatRecordShortDate(dateStr: string): string {
const date = parseRecordDateTime(dateStr)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${month}-${day}`
}
2026-05-05 18:27:36 +08:00
function formatRecordTime(dateStr: string): string {
const date = parseRecordDateTime(dateStr)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
}
2026-06-01 21:13:10 +08:00
function getRecordUserName(record: UsageRecord): string {
return record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')
}
function formatRecordUserProviderLine(record: UsageRecord): string {
2026-06-01 21:59:45 +08:00
return `${formatRecordUserSegment(record)} · ${formatRecordProviderSegment(record)}`
}
function formatRecordUserSegment(record: UsageRecord): string {
return `${getRecordUserName(record)} / ${record.api_key?.name || '-'}`
}
function formatRecordProviderSegment(record: UsageRecord): string {
return `${record.provider || '-'} / ${record.provider_key_name || '-'}`
2026-06-01 21:13:10 +08:00
}
watch(() => props.filterSearch, (value) => {
if (value !== localSearch.value) {
cancelPendingSearchEmit()
localSearch.value = value
}
})
watch(localSearch, (value) => {
if (value === props.filterSearch) return
scheduleSearchEmit(value)
})
onBeforeUnmount(() => {
cancelPendingSearchEmit()
})
2025-12-10 20:52:44 +08:00
// 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
const { isDark } = useDarkMode()
// 暗色模式下交换"流式"与"标准"徽章的填充/描边样式
function streamBadgeVariant(isStream: boolean): 'secondary' | 'outline' {
if (isDark.value) {
return isStream ? 'outline' : 'secondary'
}
return isStream ? 'secondary' : 'outline'
}
2025-12-10 20:52:44 +08:00
function handleRowMouseDown(event: MouseEvent, id: string) {
handleMouseDown(event)
if (!props.isAdmin) return
if (event.button !== 0) return
emit('prefetchDetail', id)
}
2025-12-10 20:52:44 +08:00
// 处理行点击,排除文本选择操作
function handleRowClick(event: MouseEvent, id: string) {
if (!props.isAdmin) return
if (!shouldTriggerRowClick(event)) return
emit('showDetail', id)
}
function getRecordEffectiveInputTokens(record: UsageRecord): number {
return getEffectiveInputTokens(record)
}
function getRecordCacheReadTokens(record: UsageRecord): number {
return getCacheReadTokens(record)
}
function getRecordCacheCreationTokens(record: UsageRecord): number {
return getCacheCreationTokens(record)
}
function hasPositiveTokens(value: number | null | undefined): boolean {
return typeof value === 'number' && Number.isFinite(value) && value > 0
}
function formatOptionalTokens(value: number | null | undefined): string {
return hasPositiveTokens(value) ? formatTokens(value) : '-'
}
function hasRecordCacheTokens(record: UsageRecord): boolean {
return hasPositiveTokens(getRecordCacheReadTokens(record)) || hasPositiveTokens(getRecordCacheCreationTokens(record))
}
function getRecordCacheTokensTitle(record: UsageRecord): string {
return [
`缓存读取: ${formatOptionalTokens(getRecordCacheReadTokens(record))}`,
`缓存写入: ${formatOptionalTokens(getRecordCacheCreationTokens(record))}`,
].join('\n')
}
function formatRecordLatencyPair(record: UsageRecord): string {
const firstByte = formatRecordDurationSeconds(record.first_byte_time_ms)
const total = formatRecordDurationSeconds(record.response_time_ms)
return `${firstByte} / ${total}`
}
function formatRecordDurationSeconds(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms)) return '-'
return `${(ms / 1000).toFixed(2)}s`
}
function getRecordDisplayOutputRate(record: UsageRecord): number | null {
return getDisplayOutputRate({
output_tokens: record.output_tokens,
response_time_ms: record.response_time_ms,
first_byte_time_ms: record.first_byte_time_ms,
is_stream: record.is_stream,
upstream_is_stream: record.upstream_is_stream,
})
}
function getRecordPerformanceTitle(record: UsageRecord): string {
const outputRate = getRecordDisplayOutputRate(record)
return [
`首字: ${formatRecordDurationSeconds(record.first_byte_time_ms)}`,
`总耗时: ${formatRecordDurationSeconds(record.response_time_ms)}`,
`生成耗时: ${formatRecordDurationSeconds(getGenerationTimeMs(record))}`,
`输出速度: ${formatOutputRateTokensPerSecond(outputRate)}`,
].join('\n')
}
function formatOutputRateTokensPerSecond(outputRate: number | null | undefined): string {
const value = formatOutputRateValue(outputRate)
if (value === '-') return value
return `${value} tokens/s`
}
2026-05-16 15:55:38 +08:00
function formatUserAgent(value: string | null | undefined): string {
const userAgent = value?.trim()
if (!userAgent) return '-'
return userAgent.length > 48 ? `${userAgent.slice(0, 45)}...` : userAgent
}
// useDebounceFn 自动处理清理,无需 onUnmounted
2025-12-10 20:52:44 +08:00
// 判断是否应该显示格式转换信息
2026-04-29 09:25:19 +08:00
// 包括:1. 跨格式转换(has_format_conversion=true)2. 同族格式差异
function shouldShowFormatConversion(record: UsageRecord): boolean {
if (!record.api_format || !record.endpoint_api_format) {
return false
}
// 跨格式转换
if (record.has_format_conversion) {
return true
}
// 同族格式差异(精确字符串比较,不区分大小写)
return record.api_format.trim().toLowerCase() !== record.endpoint_api_format.trim().toLowerCase()
}
// 获取 API 格式的 tooltip(包含转换信息)
function getApiFormatTooltip(record: UsageRecord): string {
if (!record.api_format) {
return ''
}
const displayFormat = formatApiFormat(record.api_format)
// 如果发生了格式转换或同族格式差异,显示详细信息
if (shouldShowFormatConversion(record)) {
const endpointApiFormat = record.endpoint_api_format ?? record.api_format
const endpointDisplayFormat = formatApiFormat(endpointApiFormat)
const conversionType = record.has_format_conversion ? '格式转换' : '格式兼容(无需转换)'
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n${conversionType}`
}
return record.api_format
}
// 获取实际使用的模型(优先 target_model,其次列表接口下发的 model_version)
// 只有当实际模型与请求模型不同时才返回,用于显示映射箭头
2025-12-10 20:52:44 +08:00
function getActualModel(record: UsageRecord): string | null {
// 优先显示模型映射
if (record.target_model && record.target_model !== record.model) {
2025-12-10 20:52:44 +08:00
return record.target_model
}
// 其次显示 Provider 返回的实际版本(如 Gemini 的 modelVersion)
if (record.model_version && record.model_version !== record.model) {
return record.model_version
2025-12-10 20:52:44 +08:00
}
return null
}
function getReasoningEffort(record: UsageRecord): string | null {
const effort = record.reasoning_effort?.trim()
return effort || null
}
function getReasoningEffortTitle(record: UsageRecord): string {
const effort = getReasoningEffort(record)
return effort ? `Reasoning: ${effort}` : ''
}
type ServiceTierBadgeState = 'confirmed' | 'downgraded' | 'upgraded' | 'pending' | 'unconfirmed'
interface ServiceTierBadgePresentation {
label: string
state: ServiceTierBadgeState
className: string
title: string
ariaLabel: string
}
function normalizeServiceTier(value: string | null | undefined): string | null {
const serviceTier = value?.trim().toLowerCase()
return serviceTier || null
}
function canonicalServiceTier(value: string | null): string | null {
if (value === 'auto' || value === 'default' || value === 'standard') {
return 'standard'
}
return value
}
function serviceTierBadgeClass(state: ServiceTierBadgeState): string {
switch (state) {
case 'confirmed':
return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
case 'downgraded':
return 'border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'
case 'upgraded':
return 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300'
case 'pending':
return 'border-dashed border-muted-foreground/30 bg-muted/30 text-muted-foreground'
case 'unconfirmed':
return 'border-dashed border-amber-500/40 bg-amber-500/5 text-amber-700 dark:text-amber-300'
}
}
function buildServiceTierBadgePresentation(
label: string,
state: ServiceTierBadgeState,
requestedRaw: string | null,
actualRaw: string | null,
billingTier: string | null,
): ServiceTierBadgePresentation {
const titleLines: string[] = []
if (requestedRaw) titleLines.push(`请求档位:${requestedRaw}`)
if (actualRaw) titleLines.push(`实际档位:${actualRaw}`)
if (billingTier) {
titleLines.push(`计费档位:${billingTier}`)
} else {
titleLines.push(`计费档位:${state === 'pending' ? '待上游确认' : '未确认'}`)
}
const title = titleLines.join('\n')
return {
label,
state,
className: serviceTierBadgeClass(state),
title,
ariaLabel: titleLines.join(','),
}
}
function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation | null {
const requestedRaw = normalizeServiceTier(record.service_tier)
const actualRaw = normalizeServiceTier(record.actual_service_tier)
const requested = canonicalServiceTier(requestedRaw)
const actual = canonicalServiceTier(actualRaw)
const requestedFast = requested === 'priority'
const actualFast = actual === 'priority'
if (actual) {
if (requestedFast && !actualFast) {
return buildServiceTierBadgePresentation(
`fast → ${actual}`,
'downgraded',
requestedRaw,
actualRaw,
actual,
)
}
if (!requestedFast && actualFast) {
const requestedLabel = requested ?? 'standard'
return buildServiceTierBadgePresentation(
requested ? `${requestedLabel} → fast` : 'fast',
requested ? 'upgraded' : 'confirmed',
requestedRaw,
actualRaw,
actual,
)
}
if (actualFast) {
return buildServiceTierBadgePresentation(
'fast',
'confirmed',
requestedRaw,
actualRaw,
actual,
)
}
return null
}
if (!requestedFast) return null
const displayStatus = getDisplayStatus(record)
const isActive = displayStatus === 'pending' || displayStatus === 'streaming'
return buildServiceTierBadgePresentation(
isActive ? 'fast · 待确认' : 'fast · 未确认',
isActive ? 'pending' : 'unconfirmed',
requestedRaw,
null,
null,
)
}
function getServiceTierTitle(record: UsageRecord): string {
const badge = getServiceTierBadge(record)
if (badge) return badge.title
const requested = normalizeServiceTier(record.service_tier)
const actual = normalizeServiceTier(record.actual_service_tier)
return [
requested ? `请求档位:${requested}` : null,
actual ? `实际档位:${actual}` : null,
].filter((line): line is string => Boolean(line)).join('\n')
}
2025-12-10 20:52:44 +08:00
// 获取模型列的 tooltip
function getModelTooltip(record: UsageRecord): string {
const actualModel = getActualModel(record)
const reasoningEffort = getReasoningEffort(record)
const serviceTierTitle = getServiceTierTitle(record)
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}`
2025-12-10 20:52:44 +08:00
if (actualModel) {
return `${record.model} -> ${actualModel}${suffix}`
2025-12-10 20:52:44 +08:00
}
return `${record.model}${suffix}`
2025-12-10 20:52:44 +08:00
}
</script>