mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
Merge upstream PR #837, preserving its original commits and resolving the UsageModelDisplay layout conflict after #841. Cover response models alongside dynamic service-tier badges in detail tests.
1342 lines
47 KiB
Vue
1342 lines
47 KiB
Vue
<template>
|
||
<div class="space-y-6 pb-8">
|
||
<!-- 面包屑旁的折叠按钮 -->
|
||
<Teleport
|
||
to="#header-actions-right"
|
||
defer
|
||
>
|
||
<button
|
||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||
:title="statsExpanded ? '收起用量分析' : '展开用量分析'"
|
||
@click="statsExpanded = !statsExpanded"
|
||
>
|
||
<PanelTopClose
|
||
v-if="statsExpanded"
|
||
class="h-4 w-4"
|
||
/>
|
||
<PanelTopOpen
|
||
v-else
|
||
class="h-4 w-4"
|
||
/>
|
||
</button>
|
||
</Teleport>
|
||
|
||
<!-- 用量分析面板(可折叠) -->
|
||
<div
|
||
v-if="statsExpanded"
|
||
class="space-y-4"
|
||
>
|
||
<!-- 活跃度热图 + 请求间隔时间线 -->
|
||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||
<ActivityHeatmapCard
|
||
:data="activityHeatmapData"
|
||
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
||
:is-loading="isLoadingHeatmap"
|
||
:has-error="heatmapError"
|
||
/>
|
||
<IntervalTimelineCard
|
||
:title="intervalTimelineTitle"
|
||
:is-admin="isAdminPage"
|
||
:hours="intervalTimelineHours"
|
||
:refresh-interval-ms="0"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 分析统计 -->
|
||
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
|
||
<div
|
||
v-if="isAdminPage"
|
||
class="grid grid-cols-1 lg:grid-cols-3 gap-4"
|
||
>
|
||
<UsageModelTable
|
||
:data="enhancedModelStats"
|
||
:is-admin="authStore.canAccessAdmin"
|
||
/>
|
||
<UsageProviderTable
|
||
:data="providerStats"
|
||
:is-admin="authStore.canAccessAdmin"
|
||
/>
|
||
<UsageApiFormatTable
|
||
:data="apiFormatStats"
|
||
:is-admin="authStore.canAccessAdmin"
|
||
/>
|
||
</div>
|
||
<!-- 用户:模型 + API格式(2列) -->
|
||
<div
|
||
v-else
|
||
class="grid grid-cols-1 lg:grid-cols-2 gap-4"
|
||
>
|
||
<UsageModelTable
|
||
:data="enhancedModelStats"
|
||
:is-admin="authStore.canAccessAdmin"
|
||
/>
|
||
<UsageApiFormatTable
|
||
:data="apiFormatStats"
|
||
:is-admin="false"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 使用记录 -->
|
||
<UsageRecordsTable
|
||
:records="displayRecords"
|
||
:is-admin="isAdminPage"
|
||
:show-actual-cost="authStore.canAccessAdmin"
|
||
:loading="isLoadingRecords"
|
||
:time-range="timeRange"
|
||
:filter-search="filterSearch"
|
||
:filter-user="filterUser"
|
||
:filter-model="filterModel"
|
||
:filter-provider="filterProvider"
|
||
:filter-api-format="filterApiFormat"
|
||
:filter-status="filterStatus"
|
||
:filter-client-family="filterClientFamily"
|
||
:available-users="availableUsers"
|
||
:available-models="availableModels"
|
||
:available-providers="availableProviders"
|
||
:available-client-families="availableClientFamilies"
|
||
:current-page="currentPage"
|
||
:page-size="pageSize"
|
||
:total-records="effectiveTotalRecords"
|
||
:page-size-options="pageSizeOptions"
|
||
:auto-refresh="globalAutoRefresh"
|
||
:hide-unknown-records="hideUnknownRecords"
|
||
@update:time-range="handleTimeRangeChange"
|
||
@update:filter-search="handleFilterSearchChange"
|
||
@update:filter-user="handleFilterUserChange"
|
||
@update:filter-model="handleFilterModelChange"
|
||
@update:filter-provider="handleFilterProviderChange"
|
||
@update:filter-api-format="handleFilterApiFormatChange"
|
||
@update:filter-status="handleFilterStatusChange"
|
||
@update:filter-client-family="handleFilterClientFamilyChange"
|
||
@update:current-page="handlePageChange"
|
||
@update:page-size="handlePageSizeChange"
|
||
@update:auto-refresh="handleAutoRefreshChange"
|
||
@update:hide-unknown-records="handleHideUnknownRecordsChange"
|
||
@refresh="handleManualRefresh"
|
||
@prefetch-detail="prefetchRequestDetail"
|
||
@show-detail="showRequestDetail"
|
||
/>
|
||
|
||
<!-- 请求详情抽屉 - 仅管理员可见 -->
|
||
<RequestDetailDrawer
|
||
v-if="isAdminPage"
|
||
:is-open="detailModalOpen"
|
||
:request-id="selectedRequestId"
|
||
:summary-record="selectedRequestSummary"
|
||
@close="detailModalOpen = false"
|
||
@request-state="handleDetailRequestState"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { useLocalStorage } from '@vueuse/core'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { usageApi } from '@/api/usage'
|
||
import type { ImageProgress } from '@/api/requestTrace'
|
||
import { usersApi } from '@/api/users'
|
||
import { meApi } from '@/api/me'
|
||
import { dashboardApi } from '@/api/dashboard'
|
||
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
|
||
import {
|
||
UsageModelTable,
|
||
UsageProviderTable,
|
||
UsageApiFormatTable,
|
||
UsageRecordsTable,
|
||
ActivityHeatmapCard,
|
||
RequestDetailDrawer,
|
||
IntervalTimelineCard
|
||
} from '@/features/usage/components'
|
||
import {
|
||
useUsageData,
|
||
getDateRangeFromPeriod
|
||
} from '@/features/usage/composables'
|
||
import { reconcileActiveRequestDiscovery } from '@/features/usage/utils/activeRequestDiscovery'
|
||
import {
|
||
mergeUsageRecordErrorMessage,
|
||
mergeUsageRecordFirstByteTimeMs,
|
||
mergeUsageRecordLifecycleSnapshot,
|
||
mergeUsageRecordResponseTiming,
|
||
parseUsageTimestampMs,
|
||
} from '@/features/usage/utils/recordSync'
|
||
import {
|
||
hasUsageFallback,
|
||
isUsageRecordFailed,
|
||
isUsageUpstreamStream,
|
||
isUsageWebSocket,
|
||
normalizeRequestStatus,
|
||
resolveDisplayRequestStatus,
|
||
} from '@/features/usage/utils/status'
|
||
import { matchesUsageRecordSearch } from '@/features/usage/utils/recordSearch'
|
||
import {
|
||
isUserLocalOnlyRecordStatus,
|
||
shouldUseServerUserRecordFilters,
|
||
} from '@/features/usage/utils/recordFilterPolicy'
|
||
import type { DateRangeParams, FilterStatusValue, RequestStatus, UsageRecord } from '@/features/usage/types'
|
||
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
||
import { log } from '@/utils/logger'
|
||
import type { ActivityHeatmap } from '@/types/activity'
|
||
import { useToast } from '@/composables/useToast'
|
||
|
||
const route = useRoute()
|
||
const { warning } = useToast()
|
||
const authStore = useAuthStore()
|
||
|
||
// 判断是否是管理员页面
|
||
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
||
|
||
// 用量分析面板折叠状态(默认展开,持久化到 localStorage)
|
||
const statsExpanded = useLocalStorage('usage-stats-expanded', true)
|
||
const hideUnknownRecords = useLocalStorage('usage-hide-unknown-records', false)
|
||
|
||
// 时间范围选择
|
||
const timeRange = ref<DateRangeParams>(
|
||
getDateRangeFromPeriod('today')
|
||
)
|
||
|
||
// 分页状态
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(20)
|
||
const pageSizeOptions = [10, 20, 50, 100]
|
||
|
||
function clampIntervalTimelineHours(hours: number): number {
|
||
return Math.min(720, Math.max(1, Math.ceil(hours)))
|
||
}
|
||
|
||
function getIntervalTimelineHours(dateRange: DateRangeParams): number {
|
||
switch (dateRange.preset) {
|
||
case 'yesterday':
|
||
return 48
|
||
case 'last7days':
|
||
return 24 * 7
|
||
case 'last30days':
|
||
return 24 * 30
|
||
case 'last90days':
|
||
return 24 * 30
|
||
case 'today':
|
||
return 24
|
||
default:
|
||
break
|
||
}
|
||
|
||
if (dateRange.start_date && dateRange.end_date) {
|
||
const start = new Date(`${dateRange.start_date}T00:00:00`)
|
||
const end = new Date(`${dateRange.end_date}T23:59:59`)
|
||
const diffMs = end.getTime() - start.getTime()
|
||
if (!Number.isNaN(diffMs) && diffMs >= 0) {
|
||
return clampIntervalTimelineHours(diffMs / (1000 * 60 * 60))
|
||
}
|
||
}
|
||
|
||
return 24
|
||
}
|
||
|
||
function formatIntervalTimelineWindow(hours: number): string {
|
||
if (hours === 24) return '最近24小时'
|
||
if (hours % 24 === 0) return `最近${hours / 24}天`
|
||
return `最近${hours}小时`
|
||
}
|
||
|
||
// 筛选状态
|
||
const filterSearch = ref('')
|
||
const filterUser = ref('__all__')
|
||
const filterModel = ref('__all__')
|
||
const filterProvider = ref('__all__')
|
||
const filterApiFormat = ref('__all__')
|
||
const filterStatus = ref<FilterStatusValue>('__all__')
|
||
const filterClientFamily = ref('__all__')
|
||
|
||
// 用户列表(仅管理员页面使用)
|
||
const availableUsers = ref<UserOption[]>([])
|
||
|
||
// 使用 composables
|
||
const {
|
||
isLoadingRecords,
|
||
providerStats,
|
||
apiFormatStats,
|
||
currentRecords,
|
||
totalRecords,
|
||
enhancedModelStats,
|
||
availableModels,
|
||
availableProviders,
|
||
loadStats,
|
||
loadRecords
|
||
} = useUsageData({ isAdminPage })
|
||
|
||
// 热力图状态
|
||
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
|
||
const isLoadingHeatmap = ref(false)
|
||
const heatmapError = ref(false)
|
||
const intervalTimelineHours = computed(() => getIntervalTimelineHours(timeRange.value))
|
||
const intervalTimelineTitle = computed(() => {
|
||
const baseTitle = isAdminPage.value ? '请求间隔时间线' : '我的请求间隔'
|
||
return `${baseTitle}(${formatIntervalTimelineWindow(intervalTimelineHours.value)})`
|
||
})
|
||
const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000
|
||
let adminAnalyticsRefreshInFlight: Promise<void> | null = null
|
||
let lastAdminAnalyticsRefreshAt = 0
|
||
let adminAnalyticsRefreshGeneration = 0
|
||
|
||
// 加载热力图数据
|
||
async function loadHeatmapData() {
|
||
isLoadingHeatmap.value = true
|
||
heatmapError.value = false
|
||
try {
|
||
if (isAdminPage.value) {
|
||
activityHeatmapData.value = await usageApi.getActivityHeatmap()
|
||
} else {
|
||
activityHeatmapData.value = await meApi.getActivityHeatmap()
|
||
}
|
||
} catch (error) {
|
||
log.error('加载热力图数据失败:', error)
|
||
heatmapError.value = true
|
||
} finally {
|
||
isLoadingHeatmap.value = false
|
||
}
|
||
}
|
||
|
||
async function loadAdminUsers() {
|
||
try {
|
||
const users = await usersApi.getAllUsers()
|
||
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
||
} catch (error) {
|
||
log.error('加载用户列表失败:', error)
|
||
}
|
||
}
|
||
|
||
async function refreshAdminAnalytics(options: { force?: boolean; preserveOnFailure?: boolean } = {}) {
|
||
if (!isAdminPage.value) return
|
||
if (!options.force && !isPageVisible.value) return
|
||
|
||
const now = Date.now()
|
||
if (!options.force && now - lastAdminAnalyticsRefreshAt < ADMIN_ANALYTICS_REFRESH_INTERVAL) {
|
||
return
|
||
}
|
||
if (!options.force && adminAnalyticsRefreshInFlight) {
|
||
return adminAnalyticsRefreshInFlight
|
||
}
|
||
if (!options.force) {
|
||
lastAdminAnalyticsRefreshAt = now
|
||
}
|
||
|
||
const refreshGeneration = ++adminAnalyticsRefreshGeneration
|
||
const refreshPromise = (async () => {
|
||
let hasSuccessfulRefresh = false
|
||
|
||
try {
|
||
const hadFailure = await loadStats(getCurrentStatsFilters(), {
|
||
force: options.force,
|
||
preserveOnFailure: options.preserveOnFailure,
|
||
})
|
||
if (refreshGeneration !== adminAnalyticsRefreshGeneration) {
|
||
return
|
||
}
|
||
hasSuccessfulRefresh = !hadFailure
|
||
if (hadFailure) {
|
||
warning('统计数据加载失败,请刷新重试')
|
||
}
|
||
} catch (error) {
|
||
if (refreshGeneration !== adminAnalyticsRefreshGeneration) {
|
||
return
|
||
}
|
||
log.error('加载统计数据失败:', error)
|
||
warning('统计数据加载失败,请刷新重试')
|
||
}
|
||
|
||
if (hasSuccessfulRefresh && refreshGeneration === adminAnalyticsRefreshGeneration) {
|
||
lastAdminAnalyticsRefreshAt = Date.now()
|
||
}
|
||
})()
|
||
adminAnalyticsRefreshInFlight = refreshPromise
|
||
|
||
try {
|
||
await refreshPromise
|
||
} finally {
|
||
if (adminAnalyticsRefreshInFlight === refreshPromise) {
|
||
adminAnalyticsRefreshInFlight = null
|
||
}
|
||
}
|
||
}
|
||
|
||
function getCurrentStatsFilters() {
|
||
const filters = getCurrentFilters()
|
||
return {
|
||
...timeRange.value,
|
||
user_id: filters.user_id,
|
||
model: filters.model,
|
||
provider: filters.provider,
|
||
}
|
||
}
|
||
|
||
async function refreshAdminAnalyticsForSelectionChange() {
|
||
if (!isAdminPage.value) return
|
||
await refreshAdminAnalytics({ force: true, preserveOnFailure: false })
|
||
}
|
||
|
||
function isUnknownUsageLabel(value: unknown): boolean {
|
||
if (typeof value !== 'string') return false
|
||
const normalized = value.trim().toLowerCase()
|
||
return normalized === 'unknown' || normalized === 'unknow'
|
||
}
|
||
|
||
function hasUnknownModelOrProvider(record: UsageRecord): boolean {
|
||
if (isUnknownUsageLabel(record.model)) return true
|
||
if (isUnknownUsageLabel(record.provider)) return true
|
||
return false
|
||
}
|
||
|
||
// 用户页面需要前端筛选;隐藏 unknown 的开关对管理员当前页也生效。
|
||
const filteredRecords = computed(() => {
|
||
let records = hideUnknownRecords.value
|
||
? currentRecords.value.filter(record => !hasUnknownModelOrProvider(record))
|
||
: [...currentRecords.value]
|
||
|
||
if (!isAdminPage.value) {
|
||
if (isUserLocalOnlyRecordStatus(filterStatus.value) && filterSearch.value.trim()) {
|
||
records = records.filter(record => matchesUsageRecordSearch(record, filterSearch.value))
|
||
}
|
||
|
||
if (filterModel.value !== '__all__') {
|
||
records = records.filter(record => record.model === filterModel.value)
|
||
}
|
||
|
||
if (filterProvider.value !== '__all__') {
|
||
records = records.filter(record => record.provider === filterProvider.value)
|
||
}
|
||
|
||
if (filterApiFormat.value !== '__all__') {
|
||
records = records.filter(record =>
|
||
record.api_format?.toUpperCase() === filterApiFormat.value.toUpperCase()
|
||
)
|
||
}
|
||
|
||
if (filterStatus.value !== '__all__') {
|
||
if (filterStatus.value === 'websocket') {
|
||
records = records.filter(record => isUsageWebSocket(record))
|
||
} else if (filterStatus.value === 'stream') {
|
||
records = records.filter(record =>
|
||
isUsageUpstreamStream(record)
|
||
&& !isUsageWebSocket(record)
|
||
&& !isUsageRecordFailed(record)
|
||
)
|
||
} else if (filterStatus.value === 'standard') {
|
||
records = records.filter(record =>
|
||
!isUsageUpstreamStream(record)
|
||
&& !isUsageWebSocket(record)
|
||
&& !isUsageRecordFailed(record)
|
||
)
|
||
} else if (filterStatus.value === 'active') {
|
||
records = records.filter(record =>
|
||
resolveDisplayRequestStatus(record) === 'pending' ||
|
||
resolveDisplayRequestStatus(record) === 'streaming'
|
||
)
|
||
} else if (filterStatus.value === 'failed') {
|
||
records = records.filter(record => isUsageRecordFailed(record))
|
||
} else if (filterStatus.value === 'cancelled') {
|
||
records = records.filter(record => record.status === 'cancelled')
|
||
} else if (filterStatus.value === 'has_fallback') {
|
||
records = records.filter(record => hasUsageFallback(record))
|
||
} else if (filterStatus.value === 'has_retry') {
|
||
records = records.filter(record => record.has_retry === true)
|
||
} else if (filterStatus.value === 'has_skipped_candidate') {
|
||
records = records.filter(record => record.has_skipped_candidate === true)
|
||
}
|
||
}
|
||
|
||
if (filterClientFamily.value !== '__all__') {
|
||
records = records.filter(record => record.client_family === filterClientFamily.value)
|
||
}
|
||
|
||
return records
|
||
}
|
||
return records
|
||
})
|
||
|
||
// 获取活跃请求的 ID 列表
|
||
const activeRequestIds = computed(() => {
|
||
return currentRecords.value
|
||
.filter((record) => {
|
||
const displayStatus = resolveDisplayRequestStatus(record)
|
||
return displayStatus === 'pending' || displayStatus === 'streaming'
|
||
})
|
||
.map(record => record.id)
|
||
})
|
||
|
||
// 检查是否有活跃请求
|
||
const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
||
|
||
// 自动刷新定时器
|
||
let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||
let activeDiscoveryTimer: ReturnType<typeof setTimeout> | null = null
|
||
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||
let refreshInFlight: Promise<void> | null = null
|
||
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
||
const ACTIVE_DISCOVERY_HOT_INTERVAL = 1000 // 有活跃请求时 1 秒扫描一次
|
||
const ACTIVE_DISCOVERY_IDLE_INTERVAL = 5000 // 空闲时降频,避免后台持续刷日志
|
||
const GLOBAL_AUTO_REFRESH_INTERVAL = 3000 // 3秒刷新一次(全局自动刷新)
|
||
const globalAutoRefresh = ref(false) // 全局自动刷新开关(默认关闭)
|
||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||
|
||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||
|
||
let pollInFlight = false
|
||
let activeDiscoveryInFlight = false
|
||
const discoveredActiveRequestIds = new Set<string>()
|
||
|
||
async function loadActiveRequestUpdates(ids?: string[]) {
|
||
if (isAdminPage.value) {
|
||
return usageApi.getActiveRequests(ids, timeRange.value)
|
||
}
|
||
const idsParam = ids?.length ? ids.join(',') : undefined
|
||
return meApi.getActiveRequests(idsParam)
|
||
}
|
||
|
||
async function pollActiveRequests() {
|
||
if (!isPageVisible.value) return
|
||
if (!hasActiveRequests.value) return
|
||
if (pollInFlight) return
|
||
pollInFlight = true
|
||
|
||
try {
|
||
const { requests } = await loadActiveRequestUpdates(activeRequestIds.value)
|
||
|
||
const recordMap = new Map(currentRecords.value.map(record => [record.id, record]))
|
||
|
||
for (const update of requests) {
|
||
const record = recordMap.get(update.id)
|
||
if (!record) continue
|
||
|
||
// 状态只允许单向推进,避免异步响应回退(pending -> streaming -> completed/failed/cancelled)
|
||
const statusPriority: Record<string, number> = {
|
||
pending: 0,
|
||
streaming: 1,
|
||
completed: 2,
|
||
failed: 2,
|
||
cancelled: 2
|
||
}
|
||
const currentRank = record.status ? (statusPriority[record.status] ?? 0) : 0
|
||
const newRank = update.status ? (statusPriority[update.status] ?? 0) : 0
|
||
const currentUpdatedAtMs = parseUsageTimestampMs(record.updated_at)
|
||
const updateUpdatedAtMs = parseUsageTimestampMs(update.updated_at)
|
||
const updateSnapshotIsOlder = currentUpdatedAtMs != null &&
|
||
updateUpdatedAtMs != null &&
|
||
updateUpdatedAtMs < currentUpdatedAtMs
|
||
const shouldApply = !updateSnapshotIsOlder && newRank >= currentRank
|
||
const updateHasFailureSignal =
|
||
(typeof update.status_code === 'number' && update.status_code >= 400) ||
|
||
(typeof update.error_message === 'string' && update.error_message.trim().length > 0) ||
|
||
update.image_progress?.phase === 'failed'
|
||
const shouldApplyData = shouldApply || (
|
||
!updateSnapshotIsOlder && currentRank < 2 && updateHasFailureSignal
|
||
)
|
||
|
||
if (shouldApply && record.status !== update.status) {
|
||
record.status = update.status
|
||
}
|
||
if (shouldApplyData) {
|
||
if ('image_progress' in update) {
|
||
record.image_progress = update.image_progress ?? null
|
||
}
|
||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||
record.input_tokens = update.input_tokens
|
||
record.effective_input_tokens = update.effective_input_tokens ?? record.effective_input_tokens
|
||
record.output_tokens = update.output_tokens
|
||
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
||
record.cache_creation_ephemeral_5m_input_tokens =
|
||
update.cache_creation_ephemeral_5m_input_tokens ?? undefined
|
||
record.cache_creation_ephemeral_1h_input_tokens =
|
||
update.cache_creation_ephemeral_1h_input_tokens ?? undefined
|
||
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
|
||
record.cost = update.cost
|
||
record.actual_cost = update.actual_cost ?? undefined
|
||
record.rate_multiplier = update.rate_multiplier ?? undefined
|
||
const responseTiming = mergeUsageRecordResponseTiming(
|
||
{
|
||
response_time_ms: record.response_time_ms,
|
||
response_time_updated_at: record.response_time_updated_at,
|
||
},
|
||
{
|
||
response_time_ms: update.response_time_ms,
|
||
response_time_updated_at: update.response_time_updated_at,
|
||
},
|
||
{
|
||
preferNext: update.status === 'completed' ||
|
||
update.status === 'failed' ||
|
||
update.status === 'cancelled',
|
||
},
|
||
)
|
||
record.response_time_ms = responseTiming.response_time_ms
|
||
record.response_time_updated_at = responseTiming.response_time_updated_at
|
||
record.first_byte_time_ms = mergeUsageRecordFirstByteTimeMs(
|
||
record.first_byte_time_ms,
|
||
update.first_byte_time_ms
|
||
)
|
||
if ('updated_at' in update) {
|
||
if (typeof update.updated_at === 'string') {
|
||
record.updated_at = update.updated_at
|
||
}
|
||
}
|
||
record.status_code = update.status_code ?? undefined
|
||
record.error_message = mergeUsageRecordErrorMessage(
|
||
record.error_message,
|
||
update.error_message,
|
||
{ authoritative: shouldApply },
|
||
)
|
||
if (typeof update.upstream_is_stream === 'boolean') {
|
||
record.upstream_is_stream = update.upstream_is_stream
|
||
record.is_stream = update.upstream_is_stream
|
||
} else if (typeof update.is_stream === 'boolean') {
|
||
record.is_stream = update.is_stream
|
||
record.upstream_is_stream = update.is_stream
|
||
}
|
||
if (typeof update.is_websocket === 'boolean') {
|
||
record.is_websocket = record.is_websocket === true || update.is_websocket
|
||
}
|
||
if (typeof update.websocket_transport === 'string' && update.websocket_transport.trim()) {
|
||
record.websocket_transport = update.websocket_transport
|
||
}
|
||
if (typeof update.usage_available === 'boolean') {
|
||
record.usage_available = record.usage_available === false || update.usage_available === false
|
||
? false
|
||
: true
|
||
}
|
||
if (typeof update.usage_pricing_available === 'boolean') {
|
||
record.usage_pricing_available = record.usage_pricing_available === false
|
||
|| update.usage_pricing_available === false
|
||
? false
|
||
: true
|
||
}
|
||
if (typeof update.input_audio_tokens === 'number') {
|
||
record.input_audio_tokens = update.input_audio_tokens
|
||
}
|
||
if (typeof update.output_audio_tokens === 'number') {
|
||
record.output_audio_tokens = update.output_audio_tokens
|
||
}
|
||
if (typeof update.client_is_stream === 'boolean') {
|
||
record.client_is_stream = update.client_is_stream
|
||
record.client_requested_stream = update.client_is_stream
|
||
} else if (typeof update.client_requested_stream === 'boolean') {
|
||
record.client_requested_stream = update.client_requested_stream
|
||
record.client_is_stream = update.client_requested_stream
|
||
}
|
||
// API 格式/格式转换:streaming 时已可确定,轮询时同步更新
|
||
if (update.api_format != null) record.api_format = update.api_format
|
||
if (update.endpoint_api_format != null) record.endpoint_api_format = update.endpoint_api_format
|
||
if (update.has_format_conversion != null) record.has_format_conversion = update.has_format_conversion
|
||
if (typeof update.has_fallback === 'boolean') {
|
||
record.has_fallback = record.has_fallback === true || update.has_fallback
|
||
}
|
||
// Active responses are complete final-provider snapshots. Absence clears facts left by
|
||
// a previous candidate, while requested reasoning remains tied to the client request.
|
||
record.target_model = typeof update.target_model === 'string' && update.target_model.trim()
|
||
? update.target_model
|
||
: null
|
||
record.reasoning_effort = typeof update.reasoning_effort === 'string' && update.reasoning_effort.trim()
|
||
? update.reasoning_effort
|
||
: null
|
||
if (typeof update.request_type === 'string' && update.request_type.trim()) {
|
||
record.request_type = update.request_type
|
||
}
|
||
if (typeof update.requested_reasoning_effort === 'string' && update.requested_reasoning_effort.trim()) {
|
||
record.requested_reasoning_effort = update.requested_reasoning_effort
|
||
}
|
||
// Active responses describe the current final provider request. Clear an old Fast fact
|
||
// when the refreshed snapshot has no request-side tier instead of retaining it forever.
|
||
record.service_tier = typeof update.service_tier === 'string' && update.service_tier.trim()
|
||
? update.service_tier
|
||
: null
|
||
record.actual_service_tier = typeof update.actual_service_tier === 'string' && update.actual_service_tier.trim()
|
||
? update.actual_service_tier
|
||
: null
|
||
// 活跃接口返回的是当前最终候选快照,服务端空值要清除旧响应模型。
|
||
record.response_model = typeof update.response_model === 'string' && update.response_model.trim()
|
||
? update.response_model
|
||
: null
|
||
// 管理员接口返回额外字段
|
||
// 只有当返回的 provider 不是 pending/unknown/unknow 时才更新,避免覆盖已有的正确值
|
||
if ('provider' in update && typeof update.provider === 'string') {
|
||
const updateProviderLabel = update.provider.trim().toLowerCase()
|
||
if (updateProviderLabel && !['pending', 'unknown', 'unknow'].includes(updateProviderLabel)) {
|
||
record.provider = update.provider
|
||
}
|
||
}
|
||
if ('api_key_name' in update) {
|
||
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
|
||
}
|
||
if ('provider_key_name' in update) {
|
||
record.provider_key_name = typeof update.provider_key_name === 'string'
|
||
? update.provider_key_name
|
||
: undefined
|
||
}
|
||
if ('client_family' in update) {
|
||
record.client_family = typeof update.client_family === 'string' ? update.client_family : null
|
||
}
|
||
if ('client_ip' in update) {
|
||
record.client_ip = typeof update.client_ip === 'string' ? update.client_ip : null
|
||
}
|
||
if ('user_agent' in update) {
|
||
record.user_agent = typeof update.user_agent === 'string' ? update.user_agent : null
|
||
}
|
||
}
|
||
}
|
||
|
||
// 不再因活跃请求完成而全表刷新,字段已在上方就地更新
|
||
// 未知请求(shouldRefresh 由 !record 触发)理论上不应出现在已知 ID 轮询中,忽略即可
|
||
} catch (error) {
|
||
log.error('轮询活跃请求状态失败:', error)
|
||
} finally {
|
||
pollInFlight = false
|
||
}
|
||
}
|
||
|
||
async function discoverActiveRequests() {
|
||
if (!isPageVisible.value) return
|
||
if (activeDiscoveryInFlight) return
|
||
if (refreshInFlight || isLoadingRecords.value) return
|
||
activeDiscoveryInFlight = true
|
||
|
||
try {
|
||
const { requests } = await loadActiveRequestUpdates()
|
||
const {
|
||
retainedDiscoveredActiveRequestIds,
|
||
unseenActiveRequestIds
|
||
} = reconcileActiveRequestDiscovery({
|
||
activeRequestIds: requests.map(request => request.id),
|
||
knownRecordIds: currentRecords.value.map(record => record.id),
|
||
discoveredActiveRequestIds
|
||
})
|
||
|
||
discoveredActiveRequestIds.clear()
|
||
retainedDiscoveredActiveRequestIds.forEach(id => discoveredActiveRequestIds.add(id))
|
||
|
||
if (unseenActiveRequestIds.length > 0) {
|
||
unseenActiveRequestIds.forEach(id => discoveredActiveRequestIds.add(id))
|
||
await refreshData()
|
||
}
|
||
} catch (error) {
|
||
log.error('发现新活跃请求失败:', error)
|
||
} finally {
|
||
activeDiscoveryInFlight = false
|
||
}
|
||
}
|
||
|
||
function scheduleNextAutoRefresh() {
|
||
if (autoRefreshTimer) return
|
||
if (!isPageVisible.value || !hasActiveRequests.value) return
|
||
autoRefreshTimer = setTimeout(async () => {
|
||
autoRefreshTimer = null
|
||
await pollActiveRequests()
|
||
scheduleNextAutoRefresh()
|
||
}, AUTO_REFRESH_INTERVAL)
|
||
}
|
||
|
||
function scheduleNextActiveDiscovery() {
|
||
if (activeDiscoveryTimer) return
|
||
if (!isPageVisible.value) return
|
||
if (!globalAutoRefresh.value) return
|
||
const interval = hasActiveRequests.value || discoveredActiveRequestIds.size > 0
|
||
? ACTIVE_DISCOVERY_HOT_INTERVAL
|
||
: ACTIVE_DISCOVERY_IDLE_INTERVAL
|
||
activeDiscoveryTimer = setTimeout(async () => {
|
||
activeDiscoveryTimer = null
|
||
await discoverActiveRequests()
|
||
scheduleNextActiveDiscovery()
|
||
}, interval)
|
||
}
|
||
|
||
// 启动自动刷新
|
||
function startAutoRefresh() {
|
||
if (!isPageVisible.value) return
|
||
scheduleNextAutoRefresh()
|
||
}
|
||
|
||
function startActiveDiscovery() {
|
||
if (!isPageVisible.value) return
|
||
if (!globalAutoRefresh.value) return
|
||
if (activeDiscoveryTimer || activeDiscoveryInFlight) return
|
||
void (async () => {
|
||
await discoverActiveRequests()
|
||
scheduleNextActiveDiscovery()
|
||
})()
|
||
}
|
||
|
||
// 停止自动刷新
|
||
function stopAutoRefresh() {
|
||
if (autoRefreshTimer) {
|
||
clearTimeout(autoRefreshTimer)
|
||
autoRefreshTimer = null
|
||
}
|
||
}
|
||
|
||
function stopActiveDiscovery() {
|
||
if (activeDiscoveryTimer) {
|
||
clearTimeout(activeDiscoveryTimer)
|
||
activeDiscoveryTimer = null
|
||
}
|
||
}
|
||
|
||
// 监听活跃请求状态,已显示的活跃行始终自刷新到终态。
|
||
// “自动刷新”开关只控制全局 3 秒刷新和新活跃请求发现。
|
||
watch(hasActiveRequests, (hasActive) => {
|
||
if (hasActive && isPageVisible.value) {
|
||
startAutoRefresh()
|
||
} else {
|
||
stopAutoRefresh()
|
||
}
|
||
}, { immediate: true })
|
||
|
||
// 启动全局自动刷新
|
||
function startGlobalAutoRefresh() {
|
||
if (!isPageVisible.value) return
|
||
if (globalAutoRefreshTimer) return
|
||
globalAutoRefreshTimer = setInterval(refreshData, GLOBAL_AUTO_REFRESH_INTERVAL)
|
||
}
|
||
|
||
// 停止全局自动刷新
|
||
function stopGlobalAutoRefresh() {
|
||
if (globalAutoRefreshTimer) {
|
||
clearInterval(globalAutoRefreshTimer)
|
||
globalAutoRefreshTimer = null
|
||
}
|
||
}
|
||
|
||
// 处理自动刷新开关变化
|
||
function handleAutoRefreshChange(value: boolean) {
|
||
globalAutoRefresh.value = value
|
||
if (value) {
|
||
if (isPageVisible.value) {
|
||
refreshData() // 立即刷新一次
|
||
startActiveDiscovery()
|
||
if (hasActiveRequests.value) {
|
||
startAutoRefresh()
|
||
}
|
||
}
|
||
startGlobalAutoRefresh()
|
||
} else {
|
||
stopActiveDiscovery()
|
||
stopGlobalAutoRefresh()
|
||
}
|
||
}
|
||
|
||
async function handleHideUnknownRecordsChange(value: boolean) {
|
||
hideUnknownRecords.value = value
|
||
currentPage.value = 1
|
||
if (isAdminPage.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
}
|
||
}
|
||
|
||
function handleVisibilityChange() {
|
||
isPageVisible.value = !document.hidden
|
||
if (!isPageVisible.value) {
|
||
stopAutoRefresh()
|
||
stopActiveDiscovery()
|
||
stopGlobalAutoRefresh()
|
||
return
|
||
}
|
||
if (hasActiveRequests.value) {
|
||
startAutoRefresh()
|
||
}
|
||
if (globalAutoRefresh.value) {
|
||
startActiveDiscovery()
|
||
refreshData()
|
||
startGlobalAutoRefresh()
|
||
}
|
||
}
|
||
|
||
// 组件卸载时清理定时器
|
||
onUnmounted(() => {
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||
stopAutoRefresh()
|
||
stopActiveDiscovery()
|
||
stopGlobalAutoRefresh()
|
||
})
|
||
|
||
// Retry/fallback are derived from the locally loaded records and are not accepted by the
|
||
// normal-user records API. Keep those statuses entirely local, including when combined with
|
||
// search/API-format filters, so an unsupported status never produces a misleading server total.
|
||
// 普通用户的 API 格式/传输类型/搜索筛选由后端执行,避免只筛选当前已加载页。
|
||
// 模型及后端不支持的 retry/fallback 筛选仍保持现有的本地分页语义。
|
||
const userUsesServerRecordFilters = computed(() => !isAdminPage.value && (
|
||
shouldUseServerUserRecordFilters({
|
||
search: filterSearch.value,
|
||
apiFormat: filterApiFormat.value,
|
||
status: filterStatus.value,
|
||
})
|
||
))
|
||
|
||
const paginatedRecords = computed(() => {
|
||
if (!isAdminPage.value && !userUsesServerRecordFilters.value) {
|
||
const start = (currentPage.value - 1) * pageSize.value
|
||
const end = start + pageSize.value
|
||
return filteredRecords.value.slice(start, end)
|
||
}
|
||
return filteredRecords.value
|
||
})
|
||
|
||
// 用户页面使用前端筛选后的总数,管理员页面使用后端返回的总数
|
||
const effectiveTotalRecords = computed(() => {
|
||
if (!isAdminPage.value && !userUsesServerRecordFilters.value) {
|
||
return filteredRecords.value.length
|
||
}
|
||
return totalRecords.value
|
||
})
|
||
|
||
// 显示的记录
|
||
const displayRecords = computed(() => paginatedRecords.value)
|
||
|
||
const availableClientFamilies = computed(() => {
|
||
const families = new Set<string>()
|
||
currentRecords.value.forEach((record) => {
|
||
const family = record.client_family?.trim()
|
||
if (family) families.add(family)
|
||
})
|
||
return Array.from(families).sort()
|
||
})
|
||
|
||
|
||
// 详情弹窗状态
|
||
const detailModalOpen = ref(false)
|
||
const selectedRequestId = ref<string | null>(null)
|
||
const selectedRequestSummary = computed(() => (
|
||
currentRecords.value.find(record => record.id === selectedRequestId.value) ?? null
|
||
))
|
||
|
||
// 初始化加载
|
||
onMounted(async () => {
|
||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||
|
||
if (isAdminPage.value) {
|
||
// 管理员页面优先启动热力图加载,避免被统计聚合链路阻塞。
|
||
const heatmapPromise = loadHeatmapData().catch(err => {
|
||
log.error('加载热力图数据失败:', err)
|
||
})
|
||
const adminUsersPromise = loadAdminUsers()
|
||
|
||
await loadRecords(
|
||
{ page: currentPage.value, pageSize: pageSize.value },
|
||
getCurrentFilters(),
|
||
timeRange.value
|
||
)
|
||
void (async () => {
|
||
await refreshAdminAnalytics({ force: true, preserveOnFailure: false })
|
||
await Promise.all([heatmapPromise, adminUsersPromise])
|
||
})()
|
||
} else {
|
||
// 用户页面:loadStats 已包含记录加载,不需要单独调用 loadRecords
|
||
await Promise.allSettled([
|
||
loadStats(timeRange.value).catch(err => {
|
||
log.error('加载统计数据失败:', err)
|
||
warning('统计数据加载失败,请刷新重试')
|
||
}),
|
||
loadHeatmapData().catch(err => {
|
||
log.error('加载热力图数据失败:', err)
|
||
})
|
||
])
|
||
}
|
||
|
||
if (globalAutoRefresh.value && isPageVisible.value) {
|
||
startActiveDiscovery()
|
||
}
|
||
|
||
if (globalAutoRefresh.value && isPageVisible.value) {
|
||
startGlobalAutoRefresh()
|
||
}
|
||
})
|
||
|
||
// 处理时间范围变化
|
||
async function handleTimeRangeChange(value: DateRangeParams) {
|
||
timeRange.value = value
|
||
currentPage.value = 1 // 重置到第一页
|
||
if (isAdminPage.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
await refreshAdminAnalyticsForSelectionChange()
|
||
return
|
||
}
|
||
await loadStats(timeRange.value)
|
||
if (userUsesServerRecordFilters.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
}
|
||
}
|
||
|
||
// 处理分页变化
|
||
async function handlePageChange(page: number) {
|
||
currentPage.value = page
|
||
if (isAdminPage.value || userUsesServerRecordFilters.value) {
|
||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
}
|
||
}
|
||
|
||
// 处理每页大小变化
|
||
async function handlePageSizeChange(size: number) {
|
||
pageSize.value = size
|
||
currentPage.value = 1 // 重置到第一页
|
||
if (isAdminPage.value || userUsesServerRecordFilters.value) {
|
||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters(), timeRange.value)
|
||
}
|
||
}
|
||
|
||
// 获取当前筛选参数
|
||
function getCurrentFilters() {
|
||
return {
|
||
search: filterSearch.value.trim() || undefined,
|
||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
|
||
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined,
|
||
client_family: filterClientFamily.value !== '__all__' ? filterClientFamily.value : undefined,
|
||
hideUnknownRecords: hideUnknownRecords.value || undefined
|
||
}
|
||
}
|
||
|
||
// 处理筛选变化
|
||
async function handleFilterSearchChange(value: string) {
|
||
filterSearch.value = value
|
||
currentPage.value = 1
|
||
|
||
if (isAdminPage.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
} else if (userUsesServerRecordFilters.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
} else {
|
||
await loadStats(timeRange.value)
|
||
}
|
||
}
|
||
|
||
async function handleFilterUserChange(value: string) {
|
||
filterUser.value = value
|
||
currentPage.value = 1 // 重置到第一页
|
||
|
||
if (isAdminPage.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
await refreshAdminAnalyticsForSelectionChange()
|
||
}
|
||
}
|
||
|
||
async function handleFilterModelChange(value: string) {
|
||
filterModel.value = value
|
||
currentPage.value = 1 // 重置到第一页
|
||
|
||
if (isAdminPage.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
await refreshAdminAnalyticsForSelectionChange()
|
||
}
|
||
}
|
||
|
||
async function handleFilterProviderChange(value: string) {
|
||
filterProvider.value = value
|
||
currentPage.value = 1
|
||
|
||
if (isAdminPage.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
await refreshAdminAnalyticsForSelectionChange()
|
||
}
|
||
}
|
||
|
||
async function handleFilterApiFormatChange(value: string) {
|
||
filterApiFormat.value = value
|
||
currentPage.value = 1
|
||
|
||
if (isAdminPage.value || userUsesServerRecordFilters.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
} else {
|
||
await loadStats(timeRange.value)
|
||
}
|
||
}
|
||
|
||
async function handleFilterStatusChange(value: string) {
|
||
filterStatus.value = value as FilterStatusValue
|
||
currentPage.value = 1
|
||
|
||
if (isAdminPage.value || userUsesServerRecordFilters.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
} else {
|
||
await loadStats(timeRange.value)
|
||
}
|
||
}
|
||
|
||
async function handleFilterClientFamilyChange(value: string) {
|
||
filterClientFamily.value = value
|
||
currentPage.value = 1
|
||
|
||
if (isAdminPage.value) {
|
||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||
}
|
||
}
|
||
|
||
// 刷新数据
|
||
async function refreshData() {
|
||
if (!isPageVisible.value) return
|
||
if (refreshInFlight) return refreshInFlight
|
||
|
||
refreshInFlight = (async () => {
|
||
if (isAdminPage.value || userUsesServerRecordFilters.value) {
|
||
await loadRecords(
|
||
{ page: currentPage.value, pageSize: pageSize.value },
|
||
getCurrentFilters(),
|
||
timeRange.value
|
||
)
|
||
return
|
||
}
|
||
|
||
await loadStats(timeRange.value)
|
||
})()
|
||
|
||
try {
|
||
await refreshInFlight
|
||
} finally {
|
||
refreshInFlight = null
|
||
}
|
||
}
|
||
|
||
async function handleManualRefresh() {
|
||
if (!isPageVisible.value) return
|
||
await refreshData()
|
||
}
|
||
|
||
// 显示请求详情
|
||
function showRequestDetail(id: string) {
|
||
if (!isAdminPage.value) return
|
||
selectedRequestId.value = id
|
||
detailModalOpen.value = true
|
||
}
|
||
|
||
function sameImageProgress(left?: ImageProgress | null, right?: ImageProgress | null): boolean {
|
||
if (!left && !right) return true
|
||
if (!left || !right) return false
|
||
return left.phase === right.phase &&
|
||
left.upstream_ttfb_ms === right.upstream_ttfb_ms &&
|
||
left.upstream_sse_frame_count === right.upstream_sse_frame_count &&
|
||
left.last_upstream_event === right.last_upstream_event &&
|
||
left.last_upstream_frame_at_unix_ms === right.last_upstream_frame_at_unix_ms &&
|
||
left.partial_image_count === right.partial_image_count &&
|
||
left.last_client_visible_event === right.last_client_visible_event &&
|
||
left.downstream_heartbeat_count === right.downstream_heartbeat_count &&
|
||
left.last_downstream_heartbeat_at_unix_ms === right.last_downstream_heartbeat_at_unix_ms &&
|
||
left.downstream_heartbeat_interval_ms === right.downstream_heartbeat_interval_ms
|
||
}
|
||
|
||
function handleDetailRequestState(update: {
|
||
id: string
|
||
status?: RequestStatus
|
||
statusCode?: number | null
|
||
inputTokens?: number | null
|
||
effectiveInputTokens?: number | null
|
||
outputTokens?: number | null
|
||
totalTokens?: number | null
|
||
cacheCreationInputTokens?: number | null
|
||
cacheCreationEphemeral5mInputTokens?: number | null
|
||
cacheCreationEphemeral1hInputTokens?: number | null
|
||
cacheReadInputTokens?: number | null
|
||
cost?: number | null
|
||
actualCost?: number | null
|
||
responseTimeMs?: number | null
|
||
firstByteTimeMs?: number | null
|
||
isStream?: boolean | null
|
||
isWebSocket?: boolean | null
|
||
websocketTransport?: string | null
|
||
usageAvailable?: boolean | null
|
||
usagePricingAvailable?: boolean | null
|
||
inputAudioTokens?: number | null
|
||
outputAudioTokens?: number | null
|
||
upstreamIsStream?: boolean | null
|
||
clientRequestedStream?: boolean | null
|
||
clientIsStream?: boolean | null
|
||
apiFormat?: string | null
|
||
endpointApiFormat?: string | null
|
||
hasFormatConversion?: boolean | null
|
||
targetModel?: string | null
|
||
requestedReasoningEffort?: string | null
|
||
reasoningEffort?: string | null
|
||
serviceTier?: string | null
|
||
actualServiceTier?: string | null
|
||
responseModel?: string | null
|
||
imageProgress?: ImageProgress | null
|
||
errorMessage?: string | null
|
||
updatedAt?: string | null
|
||
}) {
|
||
const record = currentRecords.value.find(record => record.id === update.id)
|
||
if (!record) return
|
||
|
||
const nextStatus = resolveDetailUpdateStatus(update)
|
||
const lifecycle = mergeUsageRecordLifecycleSnapshot(record, {
|
||
...(nextStatus ? { status: nextStatus } : {}),
|
||
...('statusCode' in update ? { statusCode: update.statusCode } : {}),
|
||
...('errorMessage' in update ? { errorMessage: update.errorMessage } : {}),
|
||
...('updatedAt' in update ? { updatedAt: update.updatedAt } : {}),
|
||
})
|
||
record.status = lifecycle.status
|
||
record.status_code = lifecycle.status_code
|
||
record.error_message = lifecycle.error_message
|
||
record.updated_at = lifecycle.updated_at
|
||
if (!lifecycle.accepted) return
|
||
|
||
if ('inputTokens' in update && update.inputTokens != null) {
|
||
record.input_tokens = update.inputTokens
|
||
}
|
||
if ('effectiveInputTokens' in update && update.effectiveInputTokens != null) {
|
||
record.effective_input_tokens = update.effectiveInputTokens
|
||
}
|
||
if ('outputTokens' in update && update.outputTokens != null) {
|
||
record.output_tokens = update.outputTokens
|
||
}
|
||
if ('totalTokens' in update && update.totalTokens != null) {
|
||
record.total_tokens = update.totalTokens
|
||
}
|
||
if ('cacheCreationInputTokens' in update && update.cacheCreationInputTokens != null) {
|
||
record.cache_creation_input_tokens = update.cacheCreationInputTokens
|
||
}
|
||
if ('cacheCreationEphemeral5mInputTokens' in update && update.cacheCreationEphemeral5mInputTokens != null) {
|
||
record.cache_creation_ephemeral_5m_input_tokens = update.cacheCreationEphemeral5mInputTokens
|
||
}
|
||
if ('cacheCreationEphemeral1hInputTokens' in update && update.cacheCreationEphemeral1hInputTokens != null) {
|
||
record.cache_creation_ephemeral_1h_input_tokens = update.cacheCreationEphemeral1hInputTokens
|
||
}
|
||
if ('cacheReadInputTokens' in update && update.cacheReadInputTokens != null) {
|
||
record.cache_read_input_tokens = update.cacheReadInputTokens
|
||
}
|
||
if ('cost' in update && update.cost != null) {
|
||
record.cost = update.cost
|
||
}
|
||
if ('actualCost' in update && update.actualCost != null) {
|
||
record.actual_cost = update.actualCost
|
||
}
|
||
if ('responseTimeMs' in update) {
|
||
const responseTiming = mergeUsageRecordResponseTiming(
|
||
{
|
||
response_time_ms: record.response_time_ms,
|
||
response_time_updated_at: record.response_time_updated_at,
|
||
},
|
||
{
|
||
response_time_ms: update.responseTimeMs,
|
||
response_time_updated_at: null,
|
||
},
|
||
{
|
||
preferNext: lifecycle.accepted && (
|
||
nextStatus === 'completed' ||
|
||
nextStatus === 'failed' ||
|
||
nextStatus === 'cancelled'
|
||
),
|
||
},
|
||
)
|
||
record.response_time_ms = responseTiming.response_time_ms
|
||
record.response_time_updated_at = responseTiming.response_time_updated_at
|
||
}
|
||
if ('firstByteTimeMs' in update) {
|
||
record.first_byte_time_ms = mergeUsageRecordFirstByteTimeMs(
|
||
record.first_byte_time_ms,
|
||
update.firstByteTimeMs
|
||
)
|
||
}
|
||
if ('isStream' in update && typeof update.isStream === 'boolean') {
|
||
record.is_stream = update.isStream
|
||
}
|
||
if ('isWebSocket' in update && typeof update.isWebSocket === 'boolean') {
|
||
record.is_websocket = record.is_websocket === true || update.isWebSocket
|
||
}
|
||
if (
|
||
'websocketTransport' in update
|
||
&& typeof update.websocketTransport === 'string'
|
||
&& update.websocketTransport.trim()
|
||
) {
|
||
record.websocket_transport = update.websocketTransport
|
||
}
|
||
if ('usageAvailable' in update && typeof update.usageAvailable === 'boolean') {
|
||
record.usage_available = update.usageAvailable
|
||
}
|
||
if (
|
||
'usagePricingAvailable' in update
|
||
&& typeof update.usagePricingAvailable === 'boolean'
|
||
) {
|
||
record.usage_pricing_available = update.usagePricingAvailable
|
||
}
|
||
if ('inputAudioTokens' in update && typeof update.inputAudioTokens === 'number') {
|
||
record.input_audio_tokens = update.inputAudioTokens
|
||
}
|
||
if ('outputAudioTokens' in update && typeof update.outputAudioTokens === 'number') {
|
||
record.output_audio_tokens = update.outputAudioTokens
|
||
}
|
||
if ('upstreamIsStream' in update && typeof update.upstreamIsStream === 'boolean') {
|
||
record.upstream_is_stream = update.upstreamIsStream
|
||
}
|
||
if ('clientRequestedStream' in update && typeof update.clientRequestedStream === 'boolean') {
|
||
record.client_requested_stream = update.clientRequestedStream
|
||
}
|
||
if ('clientIsStream' in update && typeof update.clientIsStream === 'boolean') {
|
||
record.client_is_stream = update.clientIsStream
|
||
}
|
||
if ('apiFormat' in update && typeof update.apiFormat === 'string') {
|
||
record.api_format = update.apiFormat
|
||
}
|
||
if ('endpointApiFormat' in update && typeof update.endpointApiFormat === 'string') {
|
||
record.endpoint_api_format = update.endpointApiFormat
|
||
}
|
||
if ('hasFormatConversion' in update && typeof update.hasFormatConversion === 'boolean') {
|
||
record.has_format_conversion = update.hasFormatConversion
|
||
}
|
||
if ('targetModel' in update) {
|
||
record.target_model = typeof update.targetModel === 'string' ? update.targetModel : update.targetModel ?? undefined
|
||
}
|
||
if ('reasoningEffort' in update) {
|
||
record.reasoning_effort = typeof update.reasoningEffort === 'string' ? update.reasoningEffort : null
|
||
}
|
||
if ('requestedReasoningEffort' in update) {
|
||
record.requested_reasoning_effort = typeof update.requestedReasoningEffort === 'string'
|
||
? update.requestedReasoningEffort
|
||
: null
|
||
}
|
||
if ('serviceTier' in update) {
|
||
record.service_tier = typeof update.serviceTier === 'string' ? update.serviceTier : null
|
||
}
|
||
if ('actualServiceTier' in update) {
|
||
record.actual_service_tier = typeof update.actualServiceTier === 'string'
|
||
? update.actualServiceTier
|
||
: null
|
||
}
|
||
if ('responseModel' in update) {
|
||
record.response_model = typeof update.responseModel === 'string'
|
||
? update.responseModel
|
||
: null
|
||
}
|
||
if ('imageProgress' in update) {
|
||
const nextProgress = update.imageProgress ?? null
|
||
if (!sameImageProgress(record.image_progress, nextProgress)) {
|
||
record.image_progress = nextProgress
|
||
}
|
||
}
|
||
}
|
||
|
||
function resolveDetailUpdateStatus(update: {
|
||
status?: RequestStatus
|
||
statusCode?: number | null
|
||
imageProgress?: ImageProgress | null
|
||
errorMessage?: string | null
|
||
}): RequestStatus | undefined {
|
||
const status = normalizeRequestStatus(update.status)
|
||
const hasFailureSignal =
|
||
(typeof update.statusCode === 'number' && update.statusCode >= 400) ||
|
||
(typeof update.errorMessage === 'string' && update.errorMessage.trim().length > 0) ||
|
||
update.imageProgress?.phase === 'failed'
|
||
|
||
if ((status == null || status === 'pending' || status === 'streaming') && hasFailureSignal) {
|
||
return 'failed'
|
||
}
|
||
return status
|
||
}
|
||
|
||
function prefetchRequestDetail(id: string) {
|
||
if (!isAdminPage.value) return
|
||
void dashboardApi.prefetchRequestDetail(id).catch(error => {
|
||
log.debug('预取请求详情失败', error)
|
||
})
|
||
}
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
</style>
|