mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
fix(usage): 抽取 ElapsedTimeText 组件、缩短缓存 TTL 提升实时性,修复 _KeyCandidate slots
- 将 UsageRecordsTable 中的内联计时逻辑抽取为独立的 ElapsedTimeText 组件 - usage records 缓存 TTL 从 15s 降为 3s,全局自动刷新间隔从 5s 改为 3s 并默认开启 - 补充 PoolManager._KeyCandidate 缺失的 __slots__ 字段
This commit is contained in:
76
frontend/src/features/usage/components/ElapsedTimeText.vue
Normal file
76
frontend/src/features/usage/components/ElapsedTimeText.vue
Normal 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>
|
||||||
@@ -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=true)2. 同族格式差异(如 CLAUDE_CLI → CLAUDE)
|
// 包括:1. 跨格式转换(has_format_conversion=true)2. 同族格式差异(如 CLAUDE_CLI → CLAUDE)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 处理时间范围变化
|
// 处理时间范围变化
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user