Files
Aether/frontend/src/features/usage/components/UsageRecordsTable.vue
T
ZheFox 7f5e1a64fe merge: integrate usage response models with service tier badges
Merge upstream PR #837, preserving its original commits and resolving the UsageModelDisplay layout conflict after #841. Cover response models alongside dynamic service-tier badges in detail tests.
2026-09-22 23:31:22 +08:00

1748 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<TableCard
title="使用记录"
class="relative"
>
<template #actions>
<!-- 时间范围筛选 -->
<TimeRangePicker
v-model="timeRangeModel"
:show-granularity="false"
class="hidden shrink-0 md:flex"
/>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 通用搜索 -->
<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"
/>
<!-- 用户筛选(仅管理员可见) -->
<ServerUserSelector
v-if="isAdmin"
class="min-w-0"
:model-value="filterUser"
:initial-users="availableUsers"
dropdown
@update:model-value="$emit('update:filterUser', $event)"
/>
<!-- 模型筛选 -->
<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">
<SelectValue placeholder="模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部模型
</SelectItem>
<SelectItem
v-for="model in availableModels"
:key="model"
:value="model"
>
{{ model.replace('claude-', '') }}
</SelectItem>
</SelectContent>
</Select>
<!-- 提供商筛选(仅管理员可见) -->
<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">
<SelectValue placeholder="提供商" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部提供商
</SelectItem>
<SelectItem
v-for="provider in availableProviders"
:key="provider"
:value="provider"
>
{{ provider }}
</SelectItem>
</SelectContent>
</Select>
<!-- 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">
<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>
<!-- 状态筛选 -->
<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">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<!-- 手机与桌面共用状态选项,避免新增筛选时漏掉手机入口。 -->
<SelectItem
v-for="option in statusFilterOptions"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 列显示配置(桌面端) -->
<MultiSelect
v-model="visibleColumnIds"
class="hidden md:block"
:options="columnSelectOptions"
placeholder="显示列"
trigger-class="w-40 h-8 text-xs border-border/60"
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>
</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 flex-wrap items-center gap-1.5">
<UsageModelDisplay
:record="record"
model-class="text-[15px] font-semibold leading-5"
stack-full-width
/>
<!-- 状态 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="isUsageWebSocket(record)"
variant="outline"
data-usage-transport="websocket"
:title="getWebSocketTransportTitle(record)"
class="whitespace-nowrap border-sky-500/50 text-sky-600 dark:text-sky-400 text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
WS
</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>
</div>
<div class="flex flex-col items-end flex-shrink-0">
<span
v-if="record.usage_available !== false && record.usage_pricing_available !== false"
class="text-sm text-primary font-semibold leading-5"
>{{ formatCurrency(record.cost || 0) }}</span>
<span
v-else-if="record.usage_available === false"
data-usage-unavailable="cost"
class="text-sm text-muted-foreground font-medium leading-5"
title="上游未提供可验证的 token/费用用量"
>不可用</span>
<span
v-else
data-usage-unpriced="cost"
class="text-sm text-muted-foreground font-medium leading-5"
title="token 用量可验证,但当前计价规则不支持该音频用量分项"
>未计价</span>
<span
v-if="record.usage_available !== false && record.usage_pricing_available !== false && 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>
<!-- 第三行:用户 + 提供商 -->
<div
v-if="isAdmin"
class="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] leading-3.5 text-muted-foreground"
>
<span
class="min-w-0 truncate"
:title="formatRecordUserProviderLine(record)"
>
{{ formatRecordUserSegment(record) }}
</span>
<span class="shrink-0 text-muted-foreground/40">·</span>
<span class="min-w-0 truncate">{{ formatRecordProviderSegment(record) }}</span>
<!-- 手机与桌面保持相同的标记优先级:发生故障转移时优先显示转移标记。 -->
<Shuffle
v-if="record.has_fallback"
data-usage-attempt-marker="fallback"
class="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0"
title="此请求发生了 Provider 故障转移"
aria-label="发生 Provider 故障转移"
/>
<Ban
v-if="record.has_skipped_candidate && !record.has_fallback"
data-usage-attempt-marker="skipped-candidate"
class="w-3.5 h-3.5 text-slate-500 dark:text-slate-400 flex-shrink-0"
:title="skippedCandidateTooltip(record)"
:aria-label="skippedCandidateAriaLabel(record)"
/>
<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"
title="此请求发生了重试"
aria-label="发生重试"
/>
</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"
: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="hasRecordDisplayLatency(record)"
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
v-if="record.usage_available !== false"
class="ml-1"
>{{ formatTokens(getRecordEffectiveInputTokens(record)) }} / {{ formatTokens(record.output_tokens || 0) }}</span>
<span
v-else
data-usage-unavailable="tokens"
class="ml-1 text-muted-foreground"
title="上游未提供可验证的 token/费用用量"
>不可用</span>
<template v-if="record.usage_available !== false && hasRecordCacheTokens(record)">
<span class="text-muted-foreground"> | </span>
<span>{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }} / {{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}</span>
</template>
<template v-if="record.usage_available !== false && ((record.input_audio_tokens || 0) > 0 || (record.output_audio_tokens || 0) > 0)">
<span class="text-muted-foreground"> | 音频 </span>
<span>{{ formatOptionalTokens(record.input_audio_tokens) }} / {{ formatOptionalTokens(record.output_audio_tokens) }}</span>
</template>
</span>
</div>
</div>
</div>
<!-- 桌面端表格视图 -->
<Table
class="hidden md:table table-fixed w-full"
:class="[desktopTableMinWidthClass]"
>
<colgroup v-if="isAdmin">
<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%]"
>
</colgroup>
<colgroup v-else>
<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%]"
>
</colgroup>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead
v-if="isColumnVisible('time')"
class="h-12 font-semibold w-[8%]"
>
时间
</TableHead>
<SortableTableHead
v-if="isAdmin && isColumnVisible('user')"
class="h-12 font-semibold w-[12%]"
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"
>
用户
<template #filter="{ close }">
<ServerUserSelector
:model-value="filterUser"
:initial-users="availableUsers"
@update:model-value="$emit('update:filterUser', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead
v-if="!isAdmin && isColumnVisible('key')"
class="h-12 font-semibold w-[17%]"
>
密钥
</TableHead>
<SortableTableHead
v-if="isColumnVisible('model')"
class="h-12 font-semibold"
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
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"
>
模型
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterModel"
:options="modelFilterOptions"
@update:model-value="$emit('update:filterModel', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
v-if="isAdmin && isColumnVisible('provider')"
class="h-12 font-semibold w-[16%]"
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"
>
提供商
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterProvider"
:options="providerFilterOptions"
@update:model-value="$emit('update:filterProvider', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
v-if="isColumnVisible('api_format')"
class="h-12 font-semibold"
:class="[isAdmin ? 'w-[15%]' : 'w-[14%]']"
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格式
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterApiFormat"
:options="apiFormatFilterOptions"
@update:model-value="$emit('update:filterApiFormat', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
v-if="isColumnVisible('status')"
class="h-12 font-semibold w-[10%] text-center"
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"
>
类型
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterStatus"
:options="statusFilterOptions"
@update:model-value="$emit('update:filterStatus', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead
v-if="isColumnVisible('tokens')"
class="h-12 font-semibold w-[10%] text-center"
>
Tokens
</TableHead>
<TableHead
v-if="isColumnVisible('cost')"
class="h-12 font-semibold w-[6%] text-right"
>
费用
</TableHead>
<TableHead
v-if="isColumnVisible('performance')"
class="h-12 font-semibold w-[9%] text-right"
>
<div class="flex flex-col items-end text-[11px] leading-3">
<span class="whitespace-nowrap">端到端</span>
<span class="whitespace-nowrap">首字/总耗时</span>
<span class="text-muted-foreground font-normal">输出速度</span>
</div>
</TableHead>
<SortableTableHead
v-if="isColumnVisible('client_family')"
class="h-12 font-semibold w-[12%]"
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>
<TableHead
v-if="isColumnVisible('client_ip')"
class="h-12 font-semibold w-[10%]"
>
IP 地址
</TableHead>
<TableHead
v-if="isColumnVisible('user_agent')"
class="h-12 font-semibold w-[13%]"
>
User-Agent
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="records.length === 0">
<TableCell
:colspan="visibleColumnCount"
class="text-center py-12 text-muted-foreground"
>
暂无请求记录
</TableCell>
</TableRow>
<TableRow
v-for="record in records"
v-else
: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)"
@click="handleRowClick($event, record.id)"
>
<TableCell
v-if="isColumnVisible('time')"
class="py-4 w-[8%] align-top"
>
<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>
</TableCell>
<TableCell
v-if="isAdmin && isColumnVisible('user')"
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>
</TableCell>
<!-- 用户页面的密钥列 -->
<TableCell
v-if="!isAdmin && isColumnVisible('key')"
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
v-if="isColumnVisible('model')"
class="font-medium py-4"
:class="[isAdmin ? 'w-[14%]' : 'w-[22%]']"
:title="getModelTooltip(record)"
>
<UsageModelDisplay
:record="record"
class="text-xs"
/>
</TableCell>
<TableCell
v-if="isAdmin && isColumnVisible('provider')"
class="py-4 w-[16%]"
>
<div class="flex min-w-0 items-center gap-1">
<div class="flex min-w-0 flex-col text-xs gap-0.5">
<span class="truncate">{{ record.provider }}</span>
<span
v-if="record.provider_key_name"
class="text-muted-foreground truncate"
:title="record.provider_key_name"
>
{{ record.provider_key_name }}
<span
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-foreground/60"
>({{ record.rate_multiplier }}x)</span>
</span>
</div>
<Shuffle
v-if="record.has_fallback"
data-usage-attempt-marker="fallback"
class="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0"
title="此请求发生了 Provider 故障转移"
aria-label="发生 Provider 故障转移"
/>
<!--
被跳过的更高优先级候选:与"故障转移"区分开。
它表示候选在调度阶段就被排除、请求从未发出,因此不会有任何上游报错,
是"无报错却换了提供商"这一现象的直接解释。
-->
<Ban
v-if="record.has_skipped_candidate && !record.has_fallback"
data-usage-attempt-marker="skipped-candidate"
class="w-3.5 h-3.5 text-slate-500 dark:text-slate-400 flex-shrink-0"
:title="skippedCandidateTooltip(record)"
:aria-label="skippedCandidateAriaLabel(record)"
/>
<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"
title="此请求发生了重试"
aria-label="发生重试"
/>
</div>
</TableCell>
<TableCell
v-if="isColumnVisible('api_format')"
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>
</TableCell>
<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="isUsageWebSocket(record)"
variant="outline"
data-usage-transport="websocket"
:title="getWebSocketTransportTitle(record)"
class="whitespace-nowrap border-sky-500/50 text-sky-600 dark:text-sky-400"
>
WS
</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>
<TableCell
v-if="isColumnVisible('tokens')"
class="py-4 w-[10%]"
>
<template v-if="record.usage_available !== false">
<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
class="justify-self-end whitespace-nowrap text-right"
:class="[
hasPositiveTokens(getRecordCacheReadTokens(record)) ? 'text-foreground/70' : ''
]"
>
{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }}
</span>
<span class="justify-self-center">
/
</span>
<span
class="justify-self-start whitespace-nowrap text-left"
:class="[
hasPositiveTokens(getRecordCacheCreationTokens(record)) ? 'text-foreground/70' : ''
]"
>
{{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}
</span>
</div>
<div
v-if="(record.input_audio_tokens || 0) > 0 || (record.output_audio_tokens || 0) > 0"
class="mt-0.5 text-right text-[10px] leading-tight tabular-nums text-muted-foreground"
>
音频 {{ formatOptionalTokens(record.input_audio_tokens) }} / {{ formatOptionalTokens(record.output_audio_tokens) }}
</div>
</template>
<div
v-else
data-usage-unavailable="tokens"
class="text-right text-xs text-muted-foreground"
title="上游未提供可验证的 token/费用用量"
>
不可用
</div>
</TableCell>
<TableCell
v-if="isColumnVisible('cost')"
class="text-right py-4 w-[6%]"
>
<div
v-if="record.usage_available !== false && record.usage_pricing_available !== false"
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"
>
{{ formatCurrency(record.actual_cost) }}
</span>
</div>
<div
v-else-if="record.usage_available === false"
data-usage-unavailable="cost"
class="text-xs text-muted-foreground"
title="上游未提供可验证的 token/费用用量"
>
不可用
</div>
<div
v-else
data-usage-unpriced="cost"
class="text-xs text-muted-foreground"
title="token 用量可验证,但当前计价规则不支持该音频用量分项"
>
未计价
</div>
</TableCell>
<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"
>
<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"
: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="hasRecordDisplayLatency(record)"
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>
</TableCell>
<TableCell
v-if="isColumnVisible('client_family')"
class="py-4 w-[12%] text-xs"
:title="formatClientFamily(record.client_family)"
>
<Badge
variant="outline"
class="w-fit max-w-full border-border/60 text-muted-foreground"
>
<span class="truncate">{{ formatClientFamily(record.client_family) }}</span>
</Badge>
</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>
</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"
@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,
SortableTableHead,
TableFilterMenu,
} from '@/components/ui'
import { Ban, EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
formatOutputRate,
formatOutputRateValue,
getDisplayOutputRate,
getGenerationTimeMs,
} from '../performance'
import {
formatUsageStreamLabel,
isUsageRecordFailed,
isUsageUpstreamStream,
isUsageWebSocket,
resolveDisplayRequestStatus,
resolveUsageStreamLabelSegments
} from '../utils/status'
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 { formatServiceTierFact } from '../utils/service-tier'
import { formatCandidateSkipReason } from '../utils/skipReason'
import { isCyberPolicyError } from '../utils/cyberError'
import { formatUsageWebSocketTransportTitle as getWebSocketTransportTitle } from '../utils/websocketTransport'
import type { DateRangeParams, UsageRecord } from '../types'
import { MultiSelect, TimeRangePicker } from '@/components/common'
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
import ElapsedTimeText from './ElapsedTimeText.vue'
import ServerUserSelector from './ServerUserSelector.vue'
import UsageModelDisplay from './UsageModelDisplay.vue'
export interface UserOption {
id: string
username: string
email: string
}
interface FilterOption {
value: string
label: string
disabled?: boolean
}
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
}
const props = defineProps<{
records: UsageRecord[]
isAdmin: boolean
showActualCost: boolean
loading: boolean
// 时间范围
timeRange: DateRangeParams
// 筛选
filterSearch: string
filterUser: string
filterModel: string
filterProvider: string
filterApiFormat: string
filterStatus: string
filterClientFamily: string
availableUsers: UserOption[]
availableModels: string[]
availableProviders: string[]
availableClientFamilies: string[]
// 分页
currentPage: number
pageSize: number
totalRecords: number
pageSizeOptions: number[]
// 自动刷新
autoRefresh: boolean
hideUnknownRecords: boolean
}>()
const emit = defineEmits<{
'update:timeRange': [value: DateRangeParams]
'update:filterSearch': [value: string]
'update:filterUser': [value: string]
'update:filterModel': [value: string]
'update:filterProvider': [value: string]
'update:filterApiFormat': [value: string]
'update:filterStatus': [value: string]
'update:filterClientFamily': [value: string]
'update:currentPage': [value: number]
'update:pageSize': [value: number]
'update:autoRefresh': [value: boolean]
'update:hideUnknownRecords': [value: boolean]
'refresh': []
'showDetail': [id: string]
'prefetchDetail': [id: string]
}>()
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),
}))
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
})
if (sanitized.length === 0) return [...fallback]
// Add newly introduced feature column to existing saved layouts, keeping it
// immediately before Tokens as the default presentation order.
return sanitized
}
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' ||
column === 'user_agent'
)).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)
}
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,
})),
])
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),
})),
]
})
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: 'HTTP 流式' },
{ value: 'standard', label: 'HTTP 标准' },
{ value: 'websocket', label: 'WebSocket (WS)' },
{ value: 'active', label: '活跃' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
{ value: 'has_retry', label: '发生重试' },
{ value: 'has_fallback', label: '发生转移' },
// 与"发生转移"区分:候选在调度阶段就被排除,请求从未发出(无上游报错)
{ value: 'has_skipped_candidate', 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)
}
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}`
}
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}`
}
function getRecordUserName(record: UsageRecord): string {
return record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')
}
function formatRecordUserProviderLine(record: UsageRecord): string {
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 || '-'}`
}
watch(() => props.filterSearch, (value) => {
if (value !== localSearch.value) {
cancelPendingSearchEmit()
localSearch.value = value
}
})
watch(localSearch, (value) => {
if (value === props.filterSearch) return
scheduleSearchEmit(value)
})
onBeforeUnmount(() => {
cancelPendingSearchEmit()
})
// 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
const { isDark } = useDarkMode()
// 暗色模式下交换"流式"与"标准"徽章的填充/描边样式
function streamBadgeVariant(isStream: boolean): 'secondary' | 'outline' {
if (isDark.value) {
return isStream ? 'outline' : 'secondary'
}
return isStream ? 'secondary' : 'outline'
}
function handleRowMouseDown(event: MouseEvent, id: string) {
handleMouseDown(event)
if (!props.isAdmin) return
if (event.button !== 0) return
emit('prefetchDetail', id)
}
// 处理行点击,排除文本选择操作
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.end_to_end_first_byte_time_ms ?? record.first_byte_time_ms,
)
const total = formatRecordDurationSeconds(record.end_to_end_time_ms ?? 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 hasRecordDisplayLatency(record: UsageRecord): boolean {
return record.end_to_end_time_ms != null
|| record.end_to_end_first_byte_time_ms != null
|| record.response_time_ms != null
|| record.first_byte_time_ms != null
}
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.end_to_end_first_byte_time_ms ?? record.first_byte_time_ms)}`,
`端到端总耗时: ${formatRecordDurationSeconds(record.end_to_end_time_ms ?? record.response_time_ms)}`,
`成功候选首字: ${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`
}
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
// 判断是否应该显示格式转换信息
// 包括: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()
}
// 被跳过候选的提示文案:把"调度阶段排除"和"上游失败"讲清楚,
// 避免用户把"无报错换提供商"误判成调度 bug 或提供商静默出错。
function skippedCandidateTooltip(record: UsageRecord): string {
const reasons = (record.skipped_candidate_reasons ?? [])
.map(formatCandidateSkipReason)
.filter((reason, index, all) => reason && all.indexOf(reason) === index)
const header = '本次有候选在调度阶段被跳过,请求未发往该候选(因此不会有上游报错)'
if (!reasons.length) return header
return `${header}\n跳过原因:${reasons.join(';')}`
}
function skippedCandidateAriaLabel(record: UsageRecord): string {
const reasons = (record.skipped_candidate_reasons ?? []).map(formatCandidateSkipReason)
return reasons.length ? `有候选被调度跳过:${reasons.join(';')}` : '有候选被调度跳过'
}
// 获取 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 displayFormat
}
function getReasoningEffort(record: UsageRecord): string | null {
const requested = record.requested_reasoning_effort?.trim()
const actual = record.reasoning_effort?.trim()
if (requested && actual && requested.toLowerCase() !== actual.toLowerCase()) {
return `${requested} -> ${actual}`
}
return actual || requested || null
}
function hasCyberPolicyError(record: UsageRecord): boolean {
return isCyberPolicyError(record.error_message)
}
interface ServiceTierBadgePresentation {
label: string
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'
}
if (value === 'fast') {
return 'priority'
}
return value
}
function buildServiceTierBadgePresentation(
requestedRaw: string | null,
): ServiceTierBadgePresentation {
const titleLines: string[] = []
const requestedLabel = formatServiceTierFact(requestedRaw)
if (requestedLabel) titleLines.push(`上游请求档位:${requestedLabel}`)
// Billing is resolved from the same final provider request tier. Keep it
// explicit in the tooltip without consulting a response-side tier.
if (requestedLabel) titleLines.push(`计费档位:${requestedLabel}`)
const title = titleLines.join('\n')
return {
label: 'Fast',
className: '!bg-transparent text-blue-500 dark:text-blue-300',
title,
ariaLabel: titleLines.join(','),
}
}
function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation | null {
const requestedRaw = normalizeServiceTier(record.service_tier)
const requested = canonicalServiceTier(requestedRaw)
const requestedFast = requested === 'priority'
if (!requestedFast) return null
return buildServiceTierBadgePresentation(requestedRaw)
}
function getServiceTierTitle(record: UsageRecord): string {
const badge = getServiceTierBadge(record)
if (badge) return badge.title
const requested = formatServiceTierFact(record.service_tier)
return [
requested ? `上游请求档位:${requested}` : null,
requested ? `计费档位:${requested}` : null,
].filter((line): line is string => Boolean(line)).join('\n')
}
// 获取模型列的 tooltip
function getModelTooltip(record: UsageRecord): string {
const reasoningEffort = getReasoningEffort(record)
const serviceTierTitle = getServiceTierTitle(record)
const tierSuffix = serviceTierTitle ? `\n${serviceTierTitle}` : ''
const cyberSuffix = hasCyberPolicyError(record) ? '\nCyber Policy: blocked' : ''
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${tierSuffix}${cyberSuffix}`
const requestModel = record.model.trim()
const mappingModel = record.target_model?.trim()
const responseModel = record.response_model?.trim()
return [
requestModel,
mappingModel && mappingModel !== requestModel ? `映射模型: ${mappingModel}` : null,
responseModel && responseModel !== requestModel ? `响应模型: ${responseModel}` : null,
].filter((line): line is string => Boolean(line)).join('\n') + suffix
}
</script>