Merge branch 'review-pr-603'

This commit is contained in:
elky
2026-05-30 21:25:58 +08:00
2 changed files with 271 additions and 143 deletions
@@ -203,27 +203,52 @@
<!-- 费用与性能概览 -->
<Card>
<div class="p-3 sm:p-4">
<div class="mb-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground/70">{{ priceSourceLabel }}</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">总费用</span>
<span class="ml-1 font-bold text-green-600 dark:text-green-400">
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
<div class="mb-4 text-sm">
<div class="sm:hidden">
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground/70">{{ priceSourceLabel }}</span>
</div>
<div class="mt-2 grid grid-cols-[max-content_max-content_max-content_max-content_max-content] items-center gap-x-2 overflow-x-auto whitespace-nowrap text-xs sm:hidden">
<span>
<span class="text-muted-foreground">总费用</span>
<span class="ml-1 font-bold text-green-600 dark:text-green-400">
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
</span>
</span>
</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">耗时</span>
<span class="ml-1 font-bold">
{{ formatDurationMs(detail.first_byte_time_ms) }} / {{ formatDurationMs(detail.response_time_ms) }}
<span class="text-muted-foreground">|</span>
<span>
<span class="text-muted-foreground">耗时</span>
<span class="ml-1 font-bold">
{{ formatDurationMs(detail.first_byte_time_ms) }} / {{ formatDurationMs(detail.response_time_ms) }}
</span>
</span>
</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">输出速度</span>
<span class="ml-1 font-bold text-primary">{{ formatOutputRateValue(detailOutputRate) }}tps</span>
</span>
<span class="text-muted-foreground">|</span>
<span>
<span class="text-muted-foreground">输出速度</span>
<span class="ml-1 font-bold text-primary">{{ formatOutputRateValue(detailOutputRate) }}tps</span>
</span>
</div>
<div class="hidden flex-wrap items-center gap-x-2 gap-y-1 sm:flex">
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground/70">{{ priceSourceLabel }}</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">总费用</span>
<span class="ml-1 font-bold text-green-600 dark:text-green-400">
${{ ((typeof detail.cost === 'object' ? detail.cost?.total : detail.cost) || detail.total_cost || 0).toFixed(6) }}
</span>
</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">耗时</span>
<span class="ml-1 font-bold">
{{ formatDurationMs(detail.first_byte_time_ms) }} / {{ formatDurationMs(detail.response_time_ms) }}
</span>
</span>
<span class="text-muted-foreground">|</span>
<span class="whitespace-nowrap">
<span class="text-muted-foreground">输出速度</span>
<span class="ml-1 font-bold text-primary">{{ formatOutputRateValue(detailOutputRate) }}tps</span>
</span>
</div>
</div>
<!-- 分隔线 -->
@@ -278,7 +303,37 @@
</Badge>
</div>
<!-- 单价信息 -->
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-muted-foreground sm:hidden">
<div class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1">
<span>输入</span>
<span class="text-right">${{ formatPrice(tier.input_price_per_1m) }}/M</span>
</div>
<div
class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1"
>
<span>输出</span>
<span class="text-right">${{ formatPrice(tier.output_price_per_1m) }}/M</span>
</div>
<template v-if="getTierActiveCacheCreationDisplay(tier) || shouldShowCacheReadPrice(tier)">
<div
v-if="getTierActiveCacheCreationDisplay(tier)"
class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1"
>
<span>{{ getTierActiveCacheCreationDisplay(tier)?.label }}</span>
<span class="text-right">${{ formatPrice(getTierActiveCacheCreationDisplay(tier)?.price || 0) }}/M</span>
</div>
<div v-else />
<div
v-if="shouldShowCacheReadPrice(tier)"
class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1"
>
<span>缓存读取</span>
<span class="text-right">${{ formatPrice(getTierActiveCacheReadPrice(tier) ?? 0) }}/M</span>
</div>
<div v-else />
</template>
</div>
<div class="text-muted-foreground hidden items-center gap-2 flex-wrap sm:flex">
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
<span v-if="getTierActiveCacheCreationDisplay(tier)">
@@ -293,8 +348,30 @@
<!-- 当前阶梯的详细计算 -->
<template v-if="index === currentTierIndex">
<div class="grid gap-y-2 sm:hidden">
<div class="grid grid-cols-[64px_minmax(0,1fr)_92px] items-center gap-x-2">
<span class="text-xs text-muted-foreground">输入</span>
<span class="text-sm font-semibold font-mono text-right tabular-nums">{{ displayInputTokens }}</span>
<span class="text-xs font-mono text-right tabular-nums">${{ effectiveInputCost.toFixed(6) }}</span>
</div>
<div class="grid grid-cols-[64px_minmax(0,1fr)_92px] items-center gap-x-2">
<span class="text-xs text-muted-foreground">输出</span>
<span class="text-sm font-semibold font-mono text-right tabular-nums">{{ detail.tokens?.output || detail.output_tokens || 0 }}</span>
<span class="text-xs font-mono text-right tabular-nums">${{ effectiveOutputCost.toFixed(6) }}</span>
</div>
<div class="grid grid-cols-[64px_minmax(0,1fr)_92px] items-center gap-x-2">
<span class="text-xs text-muted-foreground">缓存创建</span>
<span class="text-sm font-semibold font-mono text-right tabular-nums">{{ totalCacheCreationTokens }}</span>
<span class="text-xs font-mono text-right tabular-nums">${{ effectiveCacheCreationCost.toFixed(6) }}</span>
</div>
<div class="grid grid-cols-[64px_minmax(0,1fr)_92px] items-center gap-x-2">
<span class="text-xs text-muted-foreground">缓存读取</span>
<span class="text-sm font-semibold font-mono text-right tabular-nums">{{ detail.cache_read_input_tokens || 0 }}</span>
<span class="text-xs font-mono text-right tabular-nums">${{ effectiveCacheReadCost.toFixed(6) }}</span>
</div>
</div>
<!-- 输入 输出 -->
<div class="flex items-center">
<div class="hidden items-center sm:flex">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">输入</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ displayInputTokens }}</span>
@@ -311,7 +388,7 @@
</div>
</div>
<!-- 缓存创建 缓存读取 -->
<div class="flex items-center">
<div class="hidden items-center sm:flex">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">{{ cacheCreationSplitRows.length > 0 ? '创建合计' : '缓存创建' }}</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ totalCacheCreationTokens }}</span>
@@ -330,7 +407,32 @@
<!-- 缓存创建 5m/1h 细分 -->
<div
v-if="cacheCreationSplitRows.length > 0"
class="space-y-1 pl-[56px]"
class="grid gap-y-1 sm:hidden"
>
<div
v-for="row in cacheCreationSplitRows"
:key="row.key"
class="grid grid-cols-[64px_minmax(0,1fr)_68px_92px] items-center gap-x-2 text-xs text-muted-foreground/70"
>
<span>{{ row.label }}</span>
<span class="font-mono text-right tabular-nums text-foreground/90">{{ formatNumber(row.tokens) }}</span>
<span
class="text-right"
:class="row.pricePer1M === null ? 'invisible' : ''"
>
{{ row.pricePer1M !== null ? `$${formatPrice(row.pricePer1M)}/M` : '-' }}
</span>
<span
class="font-mono text-right tabular-nums"
:class="row.cost === null ? 'invisible' : ''"
>
{{ row.cost !== null ? `$${row.cost.toFixed(6)}` : '-' }}
</span>
</div>
</div>
<div
v-if="cacheCreationSplitRows.length > 0"
class="hidden space-y-1 pl-[56px] sm:block"
>
<div
v-for="row in cacheCreationSplitRows"
@@ -1,5 +1,8 @@
<template>
<TableCard title="使用记录">
<TableCard
title="使用记录"
class="relative"
>
<template #actions>
<!-- 时间范围筛选 -->
<TimeRangePicker
@@ -22,34 +25,35 @@
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
variant="ghost"
size="icon"
data-usage-hide-unknown-toggle="mobile"
class="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="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>
<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
@@ -231,15 +235,15 @@
v-for="record in records"
v-else
:key="record.id"
class="border-b border-border/40 py-2.5 px-2"
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-center justify-between gap-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1">
<span class="text-sm font-medium truncate">{{ record.model }}</span>
<div class="flex min-w-0 items-center gap-1.5">
<span class="min-w-0 truncate text-[15px] font-semibold leading-5">{{ record.model }}</span>
<Badge
v-if="getReasoningEffort(record)"
variant="outline"
@@ -256,6 +260,55 @@
>
fast
</Badge>
<!-- 状态 Badge -->
<Badge
v-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
失败
</Badge>
<Badge
v-else-if="getDisplayStatus(record) === 'pending'"
variant="outline"
class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
等待
</Badge>
<Badge
v-else-if="getDisplayStatus(record) === 'streaming'"
variant="outline"
class="whitespace-nowrap animate-pulse border-primary/50 text-primary text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
传输
</Badge>
<Badge
v-else-if="record.status === 'cancelled'"
variant="outline"
class="whitespace-nowrap border-amber-500/50 text-amber-600 dark:text-amber-400 text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0"
>
取消
</Badge>
<Badge
v-else-if="getStreamModeSegments(record).hasConversion"
:variant="streamBadgeVariant(getStreamModeSegments(record).client === '流式')"
:class="(streamBadgeVariant(getStreamModeSegments(record).client === '流式') === 'secondary')
? 'whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center gap-0.5 flex-shrink-0'
: 'whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center gap-0.5 flex-shrink-0'"
>
<span>{{ getStreamModeSegments(record).client }}</span>
<span class="opacity-60"></span>
<span>{{ getStreamModeSegments(record).upstream }}</span>
</Badge>
<Badge
v-else
:variant="streamBadgeVariant(getUpstreamStream(record))"
:class="(streamBadgeVariant(getUpstreamStream(record)) === 'secondary')
? 'whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0'
: 'whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center flex-shrink-0'"
>
{{ getStreamModeLabel(record) }}
</Badge>
</div>
<span
v-if="getActualModel(record)"
@@ -263,7 +316,7 @@
>-> {{ getActualModel(record) }}</span>
</div>
<div class="flex flex-col items-end flex-shrink-0">
<span class="text-xs text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span class="text-sm text-primary font-semibold leading-5">{{ formatCurrency(record.cost || 0) }}</span>
<span
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-[10px] text-muted-foreground"
@@ -271,79 +324,29 @@
</div>
</div>
<!-- 第二行状态 | 时间 | API格式 | 耗时 | Tokens -->
<div class="flex items-center justify-between text-[11px] text-muted-foreground mt-1 leading-4">
<div class="flex items-center gap-1.5">
<!-- 状态 Badge -->
<Badge
v-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center"
>
失败
</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"
>
等待
</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"
>
传输
</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"
>
取消
</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'
: 'whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center gap-0.5'"
>
<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'
: 'whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center'"
>
{{ getStreamModeLabel(record) }}
</Badge>
<span class="text-muted-foreground/50">|</span>
<div class="flex flex-col leading-tight tabular-nums">
<span class="text-[11px] text-foreground whitespace-nowrap">
{{ formatRecordTime(record.created_at) }}
</span>
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ formatRecordDate(record.created_at) }}
</span>
</div>
<template v-if="record.api_format">
<span class="text-muted-foreground/50">|</span>
<span>{{ formatApiFormat(record.api_format) }}</span>
</template>
</div>
<div class="flex items-center gap-1.5">
<!-- 耗时 -->
<span
v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
class="tabular-nums whitespace-nowrap"
>
<span>{{ formatRecordDurationSeconds(record.first_byte_time_ms) }}</span>
<!-- 第二行时间 + 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 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"
@@ -351,25 +354,30 @@
:status="getDisplayStatus(record)"
:response-time-ms="record.response_time_ms ?? null"
/>
</span>
<span class="text-muted-foreground"> / </span>
<span>{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
</template>
<span
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="flex flex-col items-end tabular-nums leading-3 shrink-0"
:title="getRecordPerformanceTitle(record)"
>
<span class="whitespace-nowrap">{{ formatRecordLatencyPair(record) }}</span>
<span class="text-muted-foreground tabular-nums whitespace-nowrap">
{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}
</span>
</span>
class="ml-1"
>{{ formatRecordLatencyPair(record) }} / {{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
<span
v-else
class="tabular-nums"
>-</span>
<span class="text-muted-foreground/50">|</span>
<!-- Tokens -->
<span>{{ formatTokens(getRecordEffectiveInputTokens(record)) }}/{{ formatTokens(record.output_tokens || 0) }}</span>
</div>
class="ml-1"
>- / {{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
</span>
<span class="text-muted-foreground/40">·</span>
<span
class="min-w-0 truncate whitespace-nowrap tabular-nums text-foreground"
:title="hasRecordCacheTokens(record) ? getRecordCacheTokensTitle(record) : undefined"
>
<span class="text-muted-foreground">Tokens</span>
<span class="ml-1">{{ formatTokens(getRecordEffectiveInputTokens(record)) }} / {{ formatTokens(record.output_tokens || 0) }}</span>
<template v-if="hasRecordCacheTokens(record)">
<span class="text-muted-foreground"> | </span>
<span>{{ formatOptionalTokens(getRecordCacheReadTokens(record)) }} / {{ formatOptionalTokens(getRecordCacheCreationTokens(record)) }}</span>
</template>
</span>
</div>
</div>
</div>
@@ -1364,6 +1372,13 @@ function formatRecordDate(dateStr: string): string {
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')
@@ -1428,6 +1443,17 @@ function formatOptionalTokens(value: number | null | undefined): string {
return hasPositiveTokens(value) ? formatTokens(value) : '-'
}
function hasRecordCacheTokens(record: UsageRecord): boolean {
return hasPositiveTokens(getRecordCacheReadTokens(record)) || hasPositiveTokens(getRecordCacheCreationTokens(record))
}
function getRecordCacheTokensTitle(record: UsageRecord): string {
return [
`缓存读取: ${formatOptionalTokens(getRecordCacheReadTokens(record))}`,
`缓存写入: ${formatOptionalTokens(getRecordCacheCreationTokens(record))}`,
].join('\n')
}
function formatRecordLatencyPair(record: UsageRecord): string {
const firstByte = formatRecordDurationSeconds(record.first_byte_time_ms)
const total = formatRecordDurationSeconds(record.response_time_ms)