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:
parent
cfb195c7b2
commit
cf12bc521b
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
// 图片生成字段
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user