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-05 14:00:57 +08:00
committed by QTom
co-authored by Claude Opus 4.8
parent cfb195c7b2
commit cf12bc521b
3 changed files with 40 additions and 8 deletions
+28 -1
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())
+1 -1
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
// 图片生成字段
+11 -6
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)