fix(usage): 用量明细虚拟表对可空字段渲染崩溃致整表空白

- formatDuration 兜底 null(duration_ms 后端为 *int,count_tokens/失败等请求无耗时)
- tokens/cost 单元格与 CSV 导出加 (x ?? 0) 防御
- duration_ms 前端类型改 number|null,对齐后端指针,编译器从此拦未保护用法
- DataTable 虚拟化加固:initialRect 一屏兜底 + 过滤 0 高度读数

根因:渲染对 null 调 .toFixed() 抛错→Vue 弃整个 tbody→空白;虚拟化只渲可视行故呈概率性。
非 b3847fa 引入(formatDuration 自首个 commit 即存在)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DaydreamCoding 2026-06-04 21:34:37 +08:00 committed by QTom
parent cfb195c7b2
commit cf12bc521b
3 changed files with 40 additions and 8 deletions

View File

@ -197,7 +197,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useVirtualizer, observeElementRect as observeElementRectDefault } from '@tanstack/vue-virtual'
import { useI18n } from 'vue-i18n'
import type { Column } from './types'
import Icon from '@/components/icons/Icon.vue'
@ -218,6 +218,27 @@ const tableWrapperRef = ref<HTMLElement | null>(null)
const isScrollable = ref(false)
const actionsColumnNeedsExpanding = ref(false)
// --- ---
// : .table-wrapper flex:1 / min-h-0, flex @tanstack
// observeElementRect scrollRect; 0 ( flex ,
// reflow),scrollRect 0 calculateRange null
// ( virtualizer ):
// 1) observeElementRect, height<=0 ,scrollRect 0;
// 2) initialRect ,,
// : = - /// 320px
const estimatedViewportHeight = () => {
if (typeof window === 'undefined') return 600
return Math.max(window.innerHeight - 320, 400)
}
// observeElementRect: 0 ()
const observeElementRectNonZero = (
instance: any,
cb: (rect: { width: number; height: number }) => void
) => observeElementRectDefault(instance, (rect) => {
if (rect.height > 0) cb(rect)
})
//
const checkScrollable = () => {
if (tableWrapperRef.value) {
@ -578,6 +599,12 @@ const rowVirtualizer = useVirtualizer(computed(() => ({
getScrollElement: () => tableWrapperRef.value,
estimateSize: () => props.estimateRowHeight ?? 56,
overscan: props.overscan ?? 5,
// :,,
initialRect: { width: 0, height: estimatedViewportHeight() },
// : 0 , scrollRect 0 calculateRange null
observeElementRect: observeElementRectNonZero,
// ResizeObserver rAF, reflow /
useAnimationFrameWithResizeObserver: true,
})))
const virtualItems = computed(() => rowVirtualizer.value.getVirtualItems())

View File

@ -1226,7 +1226,7 @@ export interface UsageLog {
request_type?: UsageRequestType
stream: boolean
openai_ws_mode?: boolean
duration_ms: number
duration_ms: number | null
first_token_ms: number | null
// 图片生成字段

View File

@ -160,12 +160,16 @@
</div>
<!-- 用量明细表 -->
<!-- flex 链让 DataTable .table-wrapper(flex:1)拿到有界高度以启用内部滚动
虚拟化器测高 race 导致的概率空白,已在 DataTable 内用就绪门控 + initialRect 兜底根治 -->
<div v-show="activeTab === 'usage'" class="flex min-h-0 flex-1 flex-col">
<DataTable
:columns="columns"
:data="usageLogs"
:loading="loading"
:server-side-sort="true"
:estimate-row-height="88"
:overscan="12"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
@ -236,14 +240,14 @@
<div class="inline-flex items-center gap-1">
<Icon name="arrowDown" size="sm" class="text-emerald-500" />
<span class="font-medium text-gray-900 dark:text-white">{{
row.input_tokens.toLocaleString()
(row.input_tokens ?? 0).toLocaleString()
}}</span>
</div>
<!-- Output -->
<div class="inline-flex items-center gap-1">
<Icon name="arrowUp" size="sm" class="text-violet-500" />
<span class="font-medium text-gray-900 dark:text-white">{{
row.output_tokens.toLocaleString()
(row.output_tokens ?? 0).toLocaleString()
}}</span>
</div>
</div>
@ -292,7 +296,7 @@
<template #cell-cost="{ row }">
<div class="flex items-center gap-1.5 text-sm">
<span class="font-medium text-green-600 dark:text-green-400">
${{ row.actual_cost.toFixed(6) }}
${{ (row.actual_cost ?? 0).toFixed(6) }}
</span>
<!-- Cost Detail Tooltip -->
<div
@ -701,7 +705,8 @@ const sortState = reactive({
sort_order: 'desc' as 'asc' | 'desc'
})
const formatDuration = (ms: number): string => {
const formatDuration = (ms: number | null | undefined): string => {
if (ms == null) return '-'
if (ms < 1000) return `${ms.toFixed(0)}ms`
return `${(ms / 1000).toFixed(2)}s`
}
@ -961,8 +966,8 @@ const exportToCSV = async () => {
log.cache_read_tokens,
log.cache_creation_tokens,
log.rate_multiplier,
log.actual_cost.toFixed(8),
log.total_cost.toFixed(8),
(log.actual_cost ?? 0).toFixed(8),
(log.total_cost ?? 0).toFixed(8),
log.first_token_ms ?? '',
log.duration_ms
].map(escapeCSVValue)