fix(usage): 抽取 ElapsedTimeText 组件、缩短缓存 TTL 提升实时性,修复 _KeyCandidate slots

- 将 UsageRecordsTable 中的内联计时逻辑抽取为独立的 ElapsedTimeText 组件
- usage records 缓存 TTL 从 15s 降为 3s,全局自动刷新间隔从 5s 改为 3s 并默认开启
- 补充 PoolManager._KeyCandidate 缺失的 __slots__ 字段
This commit is contained in:
fawney19
2026-03-04 02:24:46 +08:00
parent cdce817928
commit e181329a81
6 changed files with 112 additions and 59 deletions

View File

@@ -0,0 +1,76 @@
<template>
<span class="tabular-nums">{{ displayText }}</span>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
createdAt?: string | null
status?: string | null
responseTimeMs?: number | null
precision?: number
intervalMs?: number
}>(), {
createdAt: null,
status: null,
responseTimeMs: null,
precision: 2,
intervalMs: 200
})
const now = ref(Date.now())
const precision = computed(() => Math.max(0, props.precision))
const isActive = computed(() => props.status === 'pending' || props.status === 'streaming')
let timer: ReturnType<typeof setInterval> | null = null
function parseCreatedAtMs(value: string | null | undefined): number {
if (!value) return Number.NaN
// 后端有时返回无时区时间,按 UTC 解析,和列表时间显示逻辑保持一致
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
return new Date(normalized).getTime()
}
function stopTimer() {
if (!timer) return
clearInterval(timer)
timer = null
}
function startTimer() {
stopTimer()
const intervalMs = Math.max(100, props.intervalMs)
timer = setInterval(() => {
now.value = Date.now()
}, intervalMs)
}
watch([isActive, () => props.intervalMs], ([active]) => {
if (active) {
now.value = Date.now()
startTimer()
} else {
stopTimer()
}
}, { immediate: true })
onUnmounted(() => {
stopTimer()
})
const displayText = computed(() => {
if (!isActive.value) {
if (props.responseTimeMs == null) return '-'
return `${(props.responseTimeMs / 1000).toFixed(precision.value)}s`
}
if (!props.createdAt) return '-'
const createdAtMs = parseCreatedAtMs(props.createdAt)
if (Number.isNaN(createdAtMs)) return '-'
const elapsedMs = Math.max(0, now.value - createdAtMs)
return `${(elapsedMs / 1000).toFixed(precision.value)}s`
})
</script>

View File

@@ -161,7 +161,7 @@
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
:class="autoRefresh ? 'text-primary' : ''" :class="autoRefresh ? 'text-primary' : ''"
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新(每5秒刷新)'" :title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新(每3秒刷新)'"
@click="$emit('update:autoRefresh', !autoRefresh)" @click="$emit('update:autoRefresh', !autoRefresh)"
> >
<RefreshCcw <RefreshCcw
@@ -263,7 +263,11 @@
<span <span
v-if="record.status === 'pending' || record.status === 'streaming'" v-if="record.status === 'pending' || record.status === 'streaming'"
class="text-primary tabular-nums" class="text-primary tabular-nums"
>{{ getElapsedTime(record) }}</span> ><ElapsedTimeText
:created-at="record.created_at"
:status="record.status"
:response-time-ms="record.response_time_ms ?? null"
/></span>
<span <span
v-else-if="record.response_time_ms != null" v-else-if="record.response_time_ms != null"
class="tabular-nums" class="tabular-nums"
@@ -582,9 +586,11 @@
class="flex flex-col items-end text-xs gap-0.5" class="flex flex-col items-end text-xs gap-0.5"
> >
<span class="text-muted-foreground">-</span> <span class="text-muted-foreground">-</span>
<span class="text-primary tabular-nums"> <span class="text-primary tabular-nums"><ElapsedTimeText
{{ getElapsedTime(record) }} :created-at="record.created_at"
</span> :status="record.status"
:response-time-ms="record.response_time_ms ?? null"
/></span>
</div> </div>
<!-- streaming 状态首字固定 + 总时间增长 --> <!-- streaming 状态首字固定 + 总时间增长 -->
<div <div
@@ -599,9 +605,11 @@
v-else v-else
class="text-muted-foreground" class="text-muted-foreground"
>-</span> >-</span>
<span class="text-primary tabular-nums"> <span class="text-primary tabular-nums"><ElapsedTimeText
{{ getElapsedTime(record) }} :created-at="record.created_at"
</span> :status="record.status"
:response-time-ms="record.response_time_ms ?? null"
/></span>
</div> </div>
<!-- 已完成状态首字 + 总耗时 --> <!-- 已完成状态首字 + 总耗时 -->
<div <div
@@ -645,7 +653,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useDebounceFn, useIntervalFn } from '@vueuse/core' import { useDebounceFn } from '@vueuse/core'
import { import {
TableCard, TableCard,
Badge, Badge,
@@ -671,6 +679,7 @@ import { useRowClick } from '@/composables/useRowClick'
import { formatApiFormat } from '@/api/endpoints/types/api-format' import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { DateRangeParams, UsageRecord } from '../types' import type { DateRangeParams, UsageRecord } from '../types'
import { TimeRangePicker } from '@/components/common' import { TimeRangePicker } from '@/components/common'
import ElapsedTimeText from './ElapsedTimeText.vue'
export interface UserOption { export interface UserOption {
id: string id: string
@@ -756,50 +765,6 @@ watch(localSearch, (value) => {
emitSearchDebounced(value) emitSearchDebounced(value)
}) })
// 动态计时器相关
const now = ref(Date.now())
// 检查是否有活跃请求
const hasActiveRecords = computed(() => {
return props.records.some(r => r.status === 'pending' || r.status === 'streaming')
})
// 使用 VueUse 的 useIntervalFn 管理计时器(自动清理)
const { pause: stopTimer, resume: startTimer } = useIntervalFn(
() => { now.value = Date.now() },
500,
{ immediate: false }
)
// 计算活跃请求的实时耗时
function getElapsedTime(record: UsageRecord): string {
if (record.status !== 'pending' && record.status !== 'streaming') {
// 非活跃状态,显示实际响应时间
if (record.response_time_ms) {
return `${(record.response_time_ms / 1000).toFixed(2)}s`
}
return '-'
}
// 活跃状态,计算实时耗时
if (!record.created_at) return '-'
const createdAt = new Date(record.created_at).getTime()
const elapsed = now.value - createdAt
if (elapsed < 0) return '0.00s'
return `${(elapsed / 1000).toFixed(2)}s`
}
// 监听活跃记录状态,自动启动/停止计时器
watch(hasActiveRecords, (hasActive) => {
if (hasActive) {
startTimer()
} else {
stopTimer()
}
}, { immediate: true })
// 使用复用的行点击逻辑 // 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick() const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
@@ -810,7 +775,7 @@ function handleRowClick(event: MouseEvent, id: string) {
emit('showDetail', id) emit('showDetail', id)
} }
// useIntervalFn 和 useDebounceFn 自动处理清理,无需 onUnmounted // useDebounceFn 自动处理清理,无需 onUnmounted
// 判断是否应该显示格式转换信息 // 判断是否应该显示格式转换信息
// 包括1. 跨格式转换has_format_conversion=true2. 同族格式差异(如 CLAUDE_CLI → CLAUDE // 包括1. 跨格式转换has_format_conversion=true2. 同族格式差异(如 CLAUDE_CLI → CLAUDE

View File

@@ -249,8 +249,8 @@ let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
let refreshInFlight: Promise<void> | null = null let refreshInFlight: Promise<void> | null = null
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次用于活跃请求 const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次用于活跃请求
const GLOBAL_AUTO_REFRESH_INTERVAL = 5000 // 5秒刷新一次(全局自动刷新) const GLOBAL_AUTO_REFRESH_INTERVAL = 3000 // 3秒刷新一次(全局自动刷新)
const globalAutoRefresh = ref(false) // 全局自动刷新开关 const globalAutoRefresh = ref(true) // 全局自动刷新开关(默认开启)
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden) const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
// 轮询活跃请求状态(轻量级,只更新状态变化的记录) // 轮询活跃请求状态(轻量级,只更新状态变化的记录)
@@ -471,6 +471,10 @@ onMounted(async () => {
// 用户页面loadStats 已包含记录加载,不需要单独调用 loadRecords // 用户页面loadStats 已包含记录加载,不需要单独调用 loadRecords
await Promise.allSettled(tasks) await Promise.allSettled(tasks)
if (globalAutoRefresh.value && isPageVisible.value) {
startGlobalAutoRefresh()
}
}) })
// 处理时间范围变化 // 处理时间范围变化

View File

@@ -843,7 +843,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
@cache_result( @cache_result(
key_prefix="admin:usage:records", key_prefix="admin:usage:records",
ttl=CacheTTL.ADMIN_USAGE_RECORDS, ttl=3, # 使用记录页强调实时性,避免 15s 缓存导致列表滞后
user_specific=False, user_specific=False,
vary_by=[ vary_by=[
"start_date", "start_date",

View File

@@ -771,7 +771,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
@cache_result( @cache_result(
key_prefix="user:usage:records", key_prefix="user:usage:records",
ttl=CacheTTL.ADMIN_USAGE_RECORDS, ttl=3, # 使用记录页强调实时性,避免 15s 缓存导致列表滞后
user_specific=True, user_specific=True,
vary_by=[ vary_by=[
"time_range.start_date", "time_range.start_date",

View File

@@ -302,12 +302,20 @@ class PoolManager:
) )
class _KeyCandidate: class _KeyCandidate:
__slots__ = ("key", "is_skipped", "skip_reason") __slots__ = (
"key",
"is_skipped",
"skip_reason",
"_pool_extra_data",
"_pool_scheduling_trace",
)
def __init__(self, key: ProviderAPIKey) -> None: def __init__(self, key: ProviderAPIKey) -> None:
self.key = key self.key = key
self.is_skipped = False self.is_skipped = False
self.skip_reason: str | None = None self.skip_reason: str | None = None
self._pool_extra_data: dict | None = None
self._pool_scheduling_trace: PoolSchedulingTrace | None = None
wrappers = [_KeyCandidate(k) for k in keys] wrappers = [_KeyCandidate(k) for k in keys]
reordered_wrappers = await self.reorder_candidates(session_uuid, wrappers) # type: ignore[arg-type] reordered_wrappers = await self.reorder_candidates(session_uuid, wrappers) # type: ignore[arg-type]