2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
2026-05-28 22:43:20 +08:00
|
|
|
|
<TableCard
|
|
|
|
|
|
title="使用记录"
|
|
|
|
|
|
class="relative"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<template #actions>
|
2026-02-04 02:04:54 +08:00
|
|
|
|
<!-- 时间范围筛选 -->
|
|
|
|
|
|
<TimeRangePicker
|
2026-03-14 16:11:56 +08:00
|
|
|
|
v-model="timeRangeModel"
|
|
|
|
|
|
:show-granularity="false"
|
2026-05-27 09:20:10 +08:00
|
|
|
|
class="hidden shrink-0 md:flex"
|
2026-03-14 16:11:56 +08:00
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-14 16:11:56 +08:00
|
|
|
|
<!-- 分隔线 -->
|
|
|
|
|
|
<div class="hidden sm:block h-4 w-px bg-border" />
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-14 16:11:56 +08:00
|
|
|
|
<!-- 通用搜索 -->
|
2026-05-27 09:20:10 +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>
|
2026-03-14 16:11:56 +08:00
|
|
|
|
</div>
|
2026-01-05 19:32:57 +08:00
|
|
|
|
|
2026-05-28 22:43:20 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<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
|
|
|
|
<!-- 用户筛选(仅管理员可见) -->
|
2026-05-07 21:26:40 +08:00
|
|
|
|
<ServerUserSelector
|
|
|
|
|
|
v-if="isAdmin"
|
2026-05-27 09:20:10 +08:00
|
|
|
|
class="min-w-0"
|
2026-05-01 02:14:49 +08:00
|
|
|
|
:model-value="filterUser"
|
2026-05-07 21:26:40 +08:00
|
|
|
|
:initial-users="availableUsers"
|
|
|
|
|
|
dropdown
|
2026-05-01 02:14:49 +08:00
|
|
|
|
@update:model-value="$emit('update:filterUser', $event)"
|
2026-05-07 21:26:40 +08:00
|
|
|
|
/>
|
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)"
|
|
|
|
|
|
>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<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)"
|
|
|
|
|
|
>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<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)"
|
|
|
|
|
|
>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<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-02-04 02:04:54 +08:00
|
|
|
|
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<!-- 状态筛选 -->
|
|
|
|
|
|
<Select
|
|
|
|
|
|
:model-value="filterStatus"
|
|
|
|
|
|
@update:model-value="$emit('update:filterStatus', $event)"
|
|
|
|
|
|
>
|
2026-05-27 09:20:10 +08:00
|
|
|
|
<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>
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
2026-03-14 16:11:56 +08:00
|
|
|
|
<!-- 分隔线 -->
|
|
|
|
|
|
<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"
|
2026-05-27 09:20:10 +08:00
|
|
|
|
class="hidden md:block"
|
2026-05-16 15:55:38 +08:00
|
|
|
|
:options="columnSelectOptions"
|
|
|
|
|
|
placeholder="显示列"
|
2026-05-27 09:20:10 +08:00
|
|
|
|
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" />
|
|
|
|
|
|
|
2026-03-14 16:11:56 +08:00
|
|
|
|
<!-- 自动刷新按钮 -->
|
2026-05-28 20:24:48 +08:00
|
|
|
|
<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>
|
2026-03-14 16:11:56 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
2026-05-27 09:20:10 +08:00
|
|
|
|
class="hidden h-8 w-8 shrink-0 md:inline-flex"
|
2026-03-14 16:11:56 +08:00
|
|
|
|
:class="autoRefresh ? 'text-primary' : ''"
|
2026-04-10 20:58:01 +08:00
|
|
|
|
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'"
|
2026-03-14 16:11:56 +08:00
|
|
|
|
@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>
|
|
|
|
|
|
|
2026-02-05 20:13:41 +08:00
|
|
|
|
<!-- 移动端卡片视图 -->
|
|
|
|
|
|
<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"
|
2026-05-28 22:43:20 +08:00
|
|
|
|
class="border-b border-border/40 px-3 py-2.5"
|
2026-02-05 20:13:41 +08:00
|
|
|
|
:class="isAdmin ? 'cursor-pointer active:bg-muted/30 transition-colors' : ''"
|
|
|
|
|
|
@click="isAdmin && emit('showDetail', record.id)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 第一行:模型 + 费用 -->
|
2026-05-28 22:43:20 +08:00
|
|
|
|
<div class="flex items-start justify-between gap-2">
|
2026-02-05 20:13:41 +08:00
|
|
|
|
<div class="min-w-0 flex-1">
|
2026-05-28 22:43:20 +08:00
|
|
|
|
<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>
|
2026-05-27 00:36:52 +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="getFastBadge(record)"
|
|
|
|
|
|
variant="outline"
|
2026-05-27 18:38:59 +08:00
|
|
|
|
class="h-4 rounded-full px-1.5 text-[10px] leading-4 text-foreground flex-shrink-0"
|
2026-05-27 00:36:52 +08:00
|
|
|
|
:title="getFastBadgeTitle(record)"
|
|
|
|
|
|
>
|
|
|
|
|
|
fast
|
|
|
|
|
|
</Badge>
|
2026-05-28 22:43:20 +08:00
|
|
|
|
<!-- 状态 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>
|
2026-05-27 00:36:52 +08:00
|
|
|
|
</div>
|
2026-02-05 20:13:41 +08:00
|
|
|
|
<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">
|
2026-05-28 22:43:20 +08:00
|
|
|
|
<span class="text-sm text-primary font-semibold leading-5">{{ formatCurrency(record.cost || 0) }}</span>
|
2026-02-05 20:13:41 +08:00
|
|
|
|
<span
|
2026-02-07 02:59:02 +08:00
|
|
|
|
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
|
2026-02-05 20:13:41 +08:00
|
|
|
|
class="text-[10px] text-muted-foreground"
|
|
|
|
|
|
>{{ formatCurrency(record.actual_cost) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-28 22:43:20 +08:00
|
|
|
|
<!-- 第二行:时间 + 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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 第四行:性能指标 -->
|
2026-05-28 22:43:20 +08:00
|
|
|
|
<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">耗时&速度</span>
|
|
|
|
|
|
<template v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'">
|
|
|
|
|
|
<span class="ml-1">{{ formatRecordDurationSeconds(record.first_byte_time_ms) }}</span>
|
2026-05-06 17:32:23 +08:00
|
|
|
|
<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"
|
2026-05-06 17:32:23 +08:00
|
|
|
|
:status="getDisplayStatus(record)"
|
|
|
|
|
|
:response-time-ms="record.response_time_ms ?? null"
|
|
|
|
|
|
/>
|
2026-05-28 22:43:20 +08:00
|
|
|
|
<span class="text-muted-foreground"> / </span>
|
|
|
|
|
|
<span>{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
|
|
|
|
|
|
</template>
|
2026-02-05 20:13:41 +08:00
|
|
|
|
<span
|
2026-04-05 20:23:16 +08:00
|
|
|
|
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
|
2026-05-28 22:43:20 +08:00
|
|
|
|
class="ml-1"
|
|
|
|
|
|
>{{ formatRecordLatencyPair(record) }} / {{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
|
2026-02-05 20:13:41 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-else
|
2026-05-28 22:43:20 +08:00
|
|
|
|
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>
|
2026-02-05 20:13:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 桌面端表格视图 -->
|
2026-05-18 12:41:57 +08:00
|
|
|
|
<Table
|
|
|
|
|
|
class="hidden md:table table-fixed w-full"
|
2026-05-18 17:49:35 +08:00
|
|
|
|
: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%]"
|
|
|
|
|
|
>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
时间
|
|
|
|
|
|
</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="筛选用户"
|
2026-05-07 21:26:40 +08:00
|
|
|
|
filter-content-class="w-64 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
|
|
|
|
|
用户
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<template #filter="{ close }">
|
2026-05-07 21:26:40 +08:00
|
|
|
|
<ServerUserSelector
|
2026-05-01 02:14:49 +08:00
|
|
|
|
:model-value="filterUser"
|
2026-05-07 21:26:40 +08:00
|
|
|
|
:initial-users="availableUsers"
|
2026-05-01 02:14:49 +08:00
|
|
|
|
@update:model-value="$emit('update:filterUser', $event)"
|
|
|
|
|
|
@select="close"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</SortableTableHead>
|
2026-01-05 19:32:57 +08:00
|
|
|
|
<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%]"
|
2026-01-05 19:32:57 +08:00
|
|
|
|
>
|
|
|
|
|
|
密钥
|
|
|
|
|
|
</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"
|
|
|
|
|
|
>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
模型
|
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"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
|
|
|
|
|
提供商
|
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"
|
|
|
|
|
|
>
|
2026-03-14 15:01:38 +08:00
|
|
|
|
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-03-14 15:01:38 +08:00
|
|
|
|
类型
|
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"
|
|
|
|
|
|
>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
Tokens
|
|
|
|
|
|
</TableHead>
|
2026-05-28 12:11:43 +08:00
|
|
|
|
<TableHead
|
|
|
|
|
|
v-if="isColumnVisible('cost')"
|
|
|
|
|
|
class="h-12 font-semibold w-[6%] text-right"
|
|
|
|
|
|
>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
费用
|
|
|
|
|
|
</TableHead>
|
2026-05-28 12:11:43 +08:00
|
|
|
|
<TableHead
|
|
|
|
|
|
v-if="isColumnVisible('performance')"
|
|
|
|
|
|
class="h-12 font-semibold w-[9%] text-right"
|
|
|
|
|
|
>
|
2025-12-16 02:39:54 +08:00
|
|
|
|
<div class="flex flex-col items-end text-xs gap-0.5">
|
2026-05-06 17:32:23 +08:00
|
|
|
|
<span class="whitespace-nowrap">首字/总耗时</span>
|
|
|
|
|
|
<span class="text-muted-foreground font-normal">输出速度</span>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</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">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<TableCell
|
2026-05-16 15:55:38 +08:00
|
|
|
|
:colspan="visibleColumnCount"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="text-center py-12 text-muted-foreground"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
暂无请求记录
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
<TableRow
|
|
|
|
|
|
v-for="record in records"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
v-else
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:key="record.id"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
: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]'"
|
2026-04-19 15:17:25 +08:00
|
|
|
|
@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>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<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"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
|
|
|
|
|
>
|
2026-01-05 17:03:05 +08:00
|
|
|
|
<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>
|
2026-01-05 19:32:57 +08:00
|
|
|
|
<!-- 用户页面的密钥列 -->
|
|
|
|
|
|
<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%]"
|
2026-01-05 19:32:57 +08:00
|
|
|
|
: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>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<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%]']"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
:title="getModelTooltip(record)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="getActualModel(record)"
|
|
|
|
|
|
class="flex flex-col text-xs gap-0.5"
|
|
|
|
|
|
>
|
2026-05-27 00:36:52 +08:00
|
|
|
|
<div class="flex min-w-0 items-center gap-1">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span class="truncate">{{ record.model }}</span>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<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="getFastBadge(record)"
|
|
|
|
|
|
variant="outline"
|
2026-05-27 18:38:59 +08:00
|
|
|
|
class="h-4 rounded-full px-1.5 text-[10px] leading-4 text-foreground flex-shrink-0"
|
2026-05-27 17:00:09 +08:00
|
|
|
|
:title="getFastBadgeTitle(record)"
|
|
|
|
|
|
>
|
|
|
|
|
|
fast
|
|
|
|
|
|
</Badge>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<span class="text-muted-foreground truncate">{{ getActualModel(record) }}</span>
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-else
|
2026-05-27 00:36:52 +08:00
|
|
|
|
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="getFastBadge(record)"
|
|
|
|
|
|
variant="outline"
|
2026-05-27 18:38:59 +08:00
|
|
|
|
class="h-4 rounded-full px-1.5 text-[10px] leading-4 text-foreground flex-shrink-0"
|
2026-05-27 00:36:52 +08:00
|
|
|
|
:title="getFastBadgeTitle(record)"
|
|
|
|
|
|
>
|
|
|
|
|
|
fast
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</TableCell>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<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%]"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
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"
|
2026-01-28 00:04:47 +08:00
|
|
|
|
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 }}
|
2026-01-28 00:04:47 +08:00
|
|
|
|
<span
|
2026-05-19 17:23:57 +08:00
|
|
|
|
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
|
2026-01-28 00:04:47 +08:00
|
|
|
|
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
|
2026-01-28 00:04:47 +08:00
|
|
|
|
v-if="record.has_fallback"
|
2026-05-23 17:54:48 +08:00
|
|
|
|
data-usage-attempt-marker="fallback"
|
2026-01-28 00:04:47 +08:00
|
|
|
|
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"
|
2026-01-28 00:04:47 +08:00
|
|
|
|
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>
|
2026-01-28 00:04:47 +08:00
|
|
|
|
<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%]']"
|
2026-01-28 00:04:47 +08:00
|
|
|
|
:title="getApiFormatTooltip(record)"
|
|
|
|
|
|
>
|
2026-03-14 15:01:38 +08:00
|
|
|
|
<!-- 有格式转换或同族格式差异:两行显示 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="shouldShowFormatConversion(record)"
|
|
|
|
|
|
class="flex flex-col text-xs gap-0.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center gap-1 whitespace-nowrap">
|
2026-01-28 11:07:23 +08:00
|
|
|
|
<span>{{ formatApiFormat(record.api_format!) }}</span>
|
2026-01-28 00:04:47 +08:00
|
|
|
|
<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>
|
2026-03-14 15:01:38 +08:00
|
|
|
|
<span class="text-muted-foreground whitespace-nowrap">{{ formatApiFormat(record.endpoint_api_format!) }}</span>
|
2026-01-28 00:04:47 +08:00
|
|
|
|
</div>
|
2026-03-14 15:01:38 +08:00
|
|
|
|
<!-- 无格式转换:单行显示 -->
|
|
|
|
|
|
<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%]"
|
|
|
|
|
|
>
|
2026-03-14 15:01:38 +08:00
|
|
|
|
<!-- 优先显示请求状态 -->
|
|
|
|
|
|
<Badge
|
2026-05-24 18:48:50 +08:00
|
|
|
|
v-if="isUsageRecordFailed(record)"
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
class="whitespace-nowrap"
|
|
|
|
|
|
>
|
|
|
|
|
|
失败
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-else-if="getDisplayStatus(record) === 'pending'"
|
2026-03-14 15:01:38 +08:00
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
等待中
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge
|
2026-04-15 17:50:45 +08:00
|
|
|
|
v-else-if="getDisplayStatus(record) === 'streaming'"
|
2026-03-14 15:01:38 +08:00
|
|
|
|
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>
|
2026-04-23 21:53:50 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-else-if="getStreamModeSegments(record).hasConversion"
|
2026-05-27 18:38:59 +08:00
|
|
|
|
:variant="streamBadgeVariant(getStreamModeSegments(record).client === '流式')"
|
|
|
|
|
|
:class="(streamBadgeVariant(getStreamModeSegments(record).client === '流式') === 'secondary')
|
2026-04-23 21:53:50 +08:00
|
|
|
|
? '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>
|
2026-03-14 15:01:38 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-else
|
2026-05-27 18:38:59 +08:00
|
|
|
|
:variant="streamBadgeVariant(getUpstreamStream(record))"
|
|
|
|
|
|
:class="(streamBadgeVariant(getUpstreamStream(record)) === 'secondary')
|
2026-04-23 14:42:51 +08:00
|
|
|
|
? 'whitespace-nowrap'
|
|
|
|
|
|
: 'whitespace-nowrap border-border/60 text-muted-foreground'"
|
2026-03-14 15:01:38 +08:00
|
|
|
|
>
|
2026-04-23 14:42:51 +08:00
|
|
|
|
{{ getStreamModeLabel(record) }}
|
2026-03-14 15:01:38 +08:00
|
|
|
|
</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>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<span
|
2026-02-07 02:59:02 +08:00
|
|
|
|
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
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%]"
|
|
|
|
|
|
>
|
2026-05-06 17:32:23 +08:00
|
|
|
|
<!-- pending/streaming 状态:首字与动态总耗时保留在同一行 -->
|
2025-12-16 02:39:54 +08:00
|
|
|
|
<div
|
2026-05-06 17:32:23 +08:00
|
|
|
|
v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
|
2025-12-16 02:39:54 +08:00
|
|
|
|
class="flex flex-col items-end text-xs gap-0.5"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
2026-05-06 17:32:23 +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"
|
2026-05-06 17:32:23 +08:00
|
|
|
|
:status="getDisplayStatus(record)"
|
|
|
|
|
|
:response-time-ms="record.response_time_ms ?? null"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</span>
|
2025-12-22 23:44:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 已完成状态:首字 + 总耗时 -->
|
2025-12-16 02:39:54 +08:00
|
|
|
|
<div
|
2026-04-05 20:23:16 +08:00
|
|
|
|
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
|
2025-12-16 02:39:54 +08:00
|
|
|
|
class="flex flex-col items-end text-xs gap-0.5"
|
2026-05-06 17:32:23 +08:00
|
|
|
|
:title="getRecordPerformanceTitle(record)"
|
2025-12-16 02:39:54 +08:00
|
|
|
|
>
|
2026-05-06 17:32:23 +08:00
|
|
|
|
<span class="tabular-nums whitespace-nowrap">{{ formatRecordLatencyPair(record) }}</span>
|
2026-05-07 13:39:25 +08:00
|
|
|
|
<span class="text-muted-foreground tabular-nums whitespace-nowrap">
|
|
|
|
|
|
{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}
|
|
|
|
|
|
</span>
|
2025-12-16 02:39:54 +08:00
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<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"
|
2026-05-18 00:46:41 +08:00
|
|
|
|
:title="formatClientFamily(record.client_family)"
|
2026-05-16 15:55:38 +08:00
|
|
|
|
>
|
2026-05-18 00:46:41 +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"
|
2026-01-17 20:21:03 +08:00
|
|
|
|
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">
|
2026-02-04 02:04:54 +08:00
|
|
|
|
import { ref, computed, watch } from 'vue'
|
2026-05-16 15:55:38 +08:00
|
|
|
|
import { useDebounceFn, useLocalStorage } from '@vueuse/core'
|
2025-12-12 16:15:36 +08:00
|
|
|
|
import {
|
|
|
|
|
|
TableCard,
|
|
|
|
|
|
Badge,
|
2025-12-19 18:41:44 +08:00
|
|
|
|
Button,
|
2026-01-05 17:03:05 +08:00
|
|
|
|
Input,
|
2025-12-12 16:15:36 +08:00
|
|
|
|
Select,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableRow,
|
|
|
|
|
|
TableHead,
|
|
|
|
|
|
TableCell,
|
|
|
|
|
|
Pagination,
|
2026-05-01 02:14:49 +08:00
|
|
|
|
SortableTableHead,
|
|
|
|
|
|
TableFilterMenu,
|
2025-12-12 16:15:36 +08:00
|
|
|
|
} from '@/components/ui'
|
2026-05-28 20:24:48 +08:00
|
|
|
|
import { EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { formatTokens, formatCurrency } from '@/utils/format'
|
2026-04-25 09:53:32 +08:00
|
|
|
|
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
|
2026-05-06 17:32:23 +08:00
|
|
|
|
import {
|
|
|
|
|
|
formatOutputRate,
|
|
|
|
|
|
formatOutputRateValue,
|
|
|
|
|
|
getDisplayOutputRate,
|
|
|
|
|
|
getGenerationTimeMs,
|
|
|
|
|
|
} from '../performance'
|
2026-04-23 14:42:51 +08:00
|
|
|
|
import {
|
|
|
|
|
|
formatUsageStreamLabel,
|
|
|
|
|
|
isUsageRecordFailed,
|
|
|
|
|
|
isUsageUpstreamStream,
|
2026-04-23 21:53:50 +08:00
|
|
|
|
resolveDisplayRequestStatus,
|
|
|
|
|
|
resolveUsageStreamLabelSegments
|
2026-04-23 14:42:51 +08:00
|
|
|
|
} from '../utils/status'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { useRowClick } from '@/composables/useRowClick'
|
2026-05-27 18:38:59 +08:00
|
|
|
|
import { useDarkMode } from '@/composables/useDarkMode'
|
2026-05-23 16:19:23 +08:00
|
|
|
|
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
|
2026-05-22 15:28:19 +08:00
|
|
|
|
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
|
2026-02-04 02:04:54 +08:00
|
|
|
|
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'
|
2026-03-04 02:24:46 +08:00
|
|
|
|
import ElapsedTimeText from './ElapsedTimeText.vue'
|
2026-05-07 21:26:40 +08:00
|
|
|
|
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
|
2026-02-04 02:04:54 +08:00
|
|
|
|
// 时间范围
|
|
|
|
|
|
timeRange: DateRangeParams
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 筛选
|
2026-01-05 19:32:57 +08:00
|
|
|
|
filterSearch: string
|
2025-12-10 20:52:44 +08:00
|
|
|
|
filterUser: string
|
|
|
|
|
|
filterModel: string
|
|
|
|
|
|
filterProvider: string
|
2026-02-04 02:04:54 +08:00
|
|
|
|
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
|
2026-05-28 20:24:48 +08:00
|
|
|
|
hideUnknownRecords: boolean
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
2026-02-04 02:04:54 +08:00
|
|
|
|
'update:timeRange': [value: DateRangeParams]
|
2026-01-05 19:32:57 +08:00
|
|
|
|
'update:filterSearch': [value: string]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
'update:filterUser': [value: string]
|
|
|
|
|
|
'update:filterModel': [value: string]
|
|
|
|
|
|
'update:filterProvider': [value: string]
|
2026-02-04 02:04:54 +08:00
|
|
|
|
'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]
|
2026-05-28 20:24:48 +08:00
|
|
|
|
'update:hideUnknownRecords': [value: boolean]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
'refresh': []
|
|
|
|
|
|
'showDetail': [id: string]
|
2026-04-19 15:17:25 +08:00
|
|
|
|
'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',
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-23 16:19:23 +08:00
|
|
|
|
// 使用统一 API 格式枚举,避免使用记录筛选项和系统格式列表漂移。
|
|
|
|
|
|
const availableApiFormats = API_FORMAT_ORDER.map((value) => ({
|
|
|
|
|
|
value,
|
|
|
|
|
|
label: formatApiFormat(value),
|
|
|
|
|
|
}))
|
2026-02-04 02:04:54 +08:00
|
|
|
|
|
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: '发生转移' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-02-04 02:04:54 +08:00
|
|
|
|
const timeRangeModel = computed({
|
|
|
|
|
|
get: () => props.timeRange,
|
|
|
|
|
|
set: (value: DateRangeParams) => emit('update:timeRange', value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-05 19:32:57 +08:00
|
|
|
|
// 通用搜索(输入防抖)
|
|
|
|
|
|
const localSearch = ref(props.filterSearch)
|
2026-02-04 02:04:54 +08:00
|
|
|
|
const emitSearchDebounced = useDebounceFn((value: string) => {
|
|
|
|
|
|
emit('update:filterSearch', value)
|
|
|
|
|
|
}, 300)
|
2026-01-05 17:03:05 +08:00
|
|
|
|
|
2026-04-15 17:50:45 +08:00
|
|
|
|
function getDisplayStatus(record: UsageRecord) {
|
|
|
|
|
|
return resolveDisplayRequestStatus(record)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 14:42:51 +08:00
|
|
|
|
function getStreamModeLabel(record: UsageRecord): string {
|
|
|
|
|
|
return formatUsageStreamLabel(record)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 21:53:50 +08:00
|
|
|
|
function getStreamModeSegments(record: UsageRecord) {
|
|
|
|
|
|
return resolveUsageStreamLabelSegments(record)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 14:42:51 +08:00
|
|
|
|
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}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:43:20 +08:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-05 19:32:57 +08:00
|
|
|
|
watch(() => props.filterSearch, (value) => {
|
|
|
|
|
|
if (value !== localSearch.value) {
|
|
|
|
|
|
localSearch.value = value
|
2026-01-05 17:03:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-05 19:32:57 +08:00
|
|
|
|
watch(localSearch, (value) => {
|
2026-02-04 02:04:54 +08:00
|
|
|
|
emitSearchDebounced(value)
|
2026-01-05 17:03:05 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 使用复用的行点击逻辑
|
|
|
|
|
|
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
2026-05-27 18:38:59 +08:00
|
|
|
|
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
|
|
|
|
|
2026-04-19 15:17:25 +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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 17:44:55 +08:00
|
|
|
|
function getRecordEffectiveInputTokens(record: UsageRecord): number {
|
|
|
|
|
|
return getEffectiveInputTokens(record)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 09:53:32 +08:00
|
|
|
|
function getRecordCacheReadTokens(record: UsageRecord): number {
|
|
|
|
|
|
return getCacheReadTokens(record)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getRecordCacheCreationTokens(record: UsageRecord): number {
|
|
|
|
|
|
return getCacheCreationTokens(record)
|
2026-04-24 22:28:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 02:46:28 +08:00
|
|
|
|
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) : '-'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:43:20 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 17:32:23 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 02:24:46 +08:00
|
|
|
|
// useDebounceFn 自动处理清理,无需 onUnmounted
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-28 11:07:23 +08:00
|
|
|
|
// 判断是否应该显示格式转换信息
|
2026-04-29 09:25:19 +08:00
|
|
|
|
// 包括:1. 跨格式转换(has_format_conversion=true)2. 同族格式差异
|
2026-01-28 11:07:23 +08:00
|
|
|
|
function shouldShowFormatConversion(record: UsageRecord): boolean {
|
|
|
|
|
|
if (!record.api_format || !record.endpoint_api_format) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
// 跨格式转换
|
|
|
|
|
|
if (record.has_format_conversion) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
// 同族格式差异(精确字符串比较,不区分大小写)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
return record.api_format.trim().toLowerCase() !== record.endpoint_api_format.trim().toLowerCase()
|
2026-01-28 11:07:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-28 00:04:47 +08:00
|
|
|
|
// 获取 API 格式的 tooltip(包含转换信息)
|
|
|
|
|
|
function getApiFormatTooltip(record: UsageRecord): string {
|
|
|
|
|
|
if (!record.api_format) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
const displayFormat = formatApiFormat(record.api_format)
|
|
|
|
|
|
|
2026-01-28 11:07:23 +08:00
|
|
|
|
// 如果发生了格式转换或同族格式差异,显示详细信息
|
|
|
|
|
|
if (shouldShowFormatConversion(record)) {
|
2026-02-04 03:17:55 +08:00
|
|
|
|
const endpointApiFormat = record.endpoint_api_format ?? record.api_format
|
|
|
|
|
|
const endpointDisplayFormat = formatApiFormat(endpointApiFormat)
|
2026-01-28 11:07:23 +08:00
|
|
|
|
const conversionType = record.has_format_conversion ? '格式转换' : '格式兼容(无需转换)'
|
|
|
|
|
|
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n${conversionType}`
|
2026-01-28 00:04:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return record.api_format
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 00:02:49 +08:00
|
|
|
|
// 获取实际使用的模型(优先 target_model,其次列表接口下发的 model_version)
|
2025-12-15 15:05:30 +08:00
|
|
|
|
// 只有当实际模型与请求模型不同时才返回,用于显示映射箭头
|
2025-12-10 20:52:44 +08:00
|
|
|
|
function getActualModel(record: UsageRecord): string | null {
|
|
|
|
|
|
// 优先显示模型映射
|
2025-12-15 15:05:30 +08:00
|
|
|
|
if (record.target_model && record.target_model !== record.model) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return record.target_model
|
|
|
|
|
|
}
|
|
|
|
|
|
// 其次显示 Provider 返回的实际版本(如 Gemini 的 modelVersion)
|
2026-03-10 00:02:49 +08:00
|
|
|
|
if (record.model_version && record.model_version !== record.model) {
|
|
|
|
|
|
return record.model_version
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 00:36:52 +08:00
|
|
|
|
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}` : ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getServiceTier(record: UsageRecord): string | null {
|
|
|
|
|
|
const serviceTier = record.service_tier?.trim().toLowerCase()
|
|
|
|
|
|
return serviceTier || null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getFastBadge(record: UsageRecord): boolean {
|
|
|
|
|
|
return getServiceTier(record) === 'priority'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getFastBadgeTitle(record: UsageRecord): string {
|
|
|
|
|
|
const serviceTier = getServiceTier(record)
|
|
|
|
|
|
return serviceTier ? `Service tier: ${serviceTier}` : ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 获取模型列的 tooltip
|
|
|
|
|
|
function getModelTooltip(record: UsageRecord): string {
|
|
|
|
|
|
const actualModel = getActualModel(record)
|
2026-05-27 00:36:52 +08:00
|
|
|
|
const reasoningEffort = getReasoningEffort(record)
|
|
|
|
|
|
const fastSuffix = getFastBadge(record) ? '\nService tier: priority' : ''
|
|
|
|
|
|
const suffix = `${reasoningEffort ? `\nReasoning: ${reasoningEffort}` : ''}${fastSuffix}`
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (actualModel) {
|
2026-05-27 00:36:52 +08:00
|
|
|
|
return `${record.model} -> ${actualModel}${suffix}`
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
2026-05-27 00:36:52 +08:00
|
|
|
|
return `${record.model}${suffix}`
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
</script>
|