2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="space-y-6 pb-8">
|
2026-03-14 13:28:56 +08:00
|
|
|
|
<!-- 面包屑旁的折叠按钮 -->
|
2026-03-14 16:11:56 +08:00
|
|
|
|
<Teleport
|
|
|
|
|
|
to="#header-actions-right"
|
|
|
|
|
|
defer
|
|
|
|
|
|
>
|
2026-03-14 13:28:56 +08:00
|
|
|
|
<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
|
2026-04-19 15:17:25 +08:00
|
|
|
|
:title="intervalTimelineTitle"
|
2026-03-14 13:28:56 +08:00
|
|
|
|
:is-admin="isAdminPage"
|
2026-04-19 15:17:25 +08:00
|
|
|
|
:hours="intervalTimelineHours"
|
2026-04-19 16:07:01 +08:00
|
|
|
|
:refresh-interval-ms="0"
|
2026-03-14 13:28:56 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 分析统计 -->
|
|
|
|
|
|
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="isAdminPage"
|
|
|
|
|
|
class="grid grid-cols-1 lg:grid-cols-3 gap-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UsageModelTable
|
|
|
|
|
|
:data="enhancedModelStats"
|
2026-05-18 12:41:57 +08:00
|
|
|
|
:is-admin="authStore.canAccessAdmin"
|
2026-03-14 13:28:56 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<UsageProviderTable
|
|
|
|
|
|
:data="providerStats"
|
2026-05-18 12:41:57 +08:00
|
|
|
|
:is-admin="authStore.canAccessAdmin"
|
2026-03-14 13:28:56 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<UsageApiFormatTable
|
|
|
|
|
|
:data="apiFormatStats"
|
2026-05-18 12:41:57 +08:00
|
|
|
|
:is-admin="authStore.canAccessAdmin"
|
2026-03-14 13:28:56 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 用户:模型 + API格式(2列) -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="grid grid-cols-1 lg:grid-cols-2 gap-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UsageModelTable
|
|
|
|
|
|
:data="enhancedModelStats"
|
2026-05-18 12:41:57 +08:00
|
|
|
|
:is-admin="authStore.canAccessAdmin"
|
2026-03-14 13:28:56 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<UsageApiFormatTable
|
|
|
|
|
|
:data="apiFormatStats"
|
|
|
|
|
|
:is-admin="false"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-12-11 17:52:32 +08:00
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-14 13:28:56 +08:00
|
|
|
|
<!-- 使用记录 -->
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<UsageRecordsTable
|
|
|
|
|
|
:records="displayRecords"
|
|
|
|
|
|
:is-admin="isAdminPage"
|
2026-05-18 12:41:57 +08:00
|
|
|
|
:show-actual-cost="authStore.canAccessAdmin"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:loading="isLoadingRecords"
|
2026-02-04 02:04:54 +08:00
|
|
|
|
:time-range="timeRange"
|
2026-01-05 19:32:57 +08:00
|
|
|
|
:filter-search="filterSearch"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:filter-user="filterUser"
|
|
|
|
|
|
:filter-model="filterModel"
|
|
|
|
|
|
:filter-provider="filterProvider"
|
2026-02-04 02:04:54 +08:00
|
|
|
|
:filter-api-format="filterApiFormat"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:filter-status="filterStatus"
|
2026-05-16 15:55:38 +08:00
|
|
|
|
:filter-client-family="filterClientFamily"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:available-users="availableUsers"
|
|
|
|
|
|
:available-models="availableModels"
|
|
|
|
|
|
:available-providers="availableProviders"
|
2026-05-16 15:55:38 +08:00
|
|
|
|
:available-client-families="availableClientFamilies"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:current-page="currentPage"
|
|
|
|
|
|
:page-size="pageSize"
|
2026-03-01 18:20:42 +08:00
|
|
|
|
:total-records="effectiveTotalRecords"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:page-size-options="pageSizeOptions"
|
2025-12-19 17:31:15 +08:00
|
|
|
|
:auto-refresh="globalAutoRefresh"
|
2026-05-28 20:24:48 +08:00
|
|
|
|
:hide-unknown-records="hideUnknownRecords"
|
2026-02-04 02:04:54 +08:00
|
|
|
|
@update:time-range="handleTimeRangeChange"
|
2026-01-05 19:32:57 +08:00
|
|
|
|
@update:filter-search="handleFilterSearchChange"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@update:filter-user="handleFilterUserChange"
|
|
|
|
|
|
@update:filter-model="handleFilterModelChange"
|
|
|
|
|
|
@update:filter-provider="handleFilterProviderChange"
|
2026-02-04 02:04:54 +08:00
|
|
|
|
@update:filter-api-format="handleFilterApiFormatChange"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@update:filter-status="handleFilterStatusChange"
|
2026-05-16 15:55:38 +08:00
|
|
|
|
@update:filter-client-family="handleFilterClientFamilyChange"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@update:current-page="handlePageChange"
|
|
|
|
|
|
@update:page-size="handlePageSizeChange"
|
2025-12-19 17:31:15 +08:00
|
|
|
|
@update:auto-refresh="handleAutoRefreshChange"
|
2026-05-28 20:24:48 +08:00
|
|
|
|
@update:hide-unknown-records="handleHideUnknownRecordsChange"
|
2026-05-20 16:51:52 +08:00
|
|
|
|
@refresh="handleManualRefresh"
|
2026-04-19 15:17:25 +08:00
|
|
|
|
@prefetch-detail="prefetchRequestDetail"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@show-detail="showRequestDetail"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 请求详情抽屉 - 仅管理员可见 -->
|
|
|
|
|
|
<RequestDetailDrawer
|
|
|
|
|
|
v-if="isAdminPage"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
:is-open="detailModalOpen"
|
|
|
|
|
|
:request-id="selectedRequestId"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@close="detailModalOpen = false"
|
2026-05-09 01:21:26 +08:00
|
|
|
|
@request-state="handleDetailRequestState"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
|
|
|
|
import { useRoute } from 'vue-router'
|
2026-03-14 13:28:56 +08:00
|
|
|
|
import { useLocalStorage } from '@vueuse/core'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
|
|
import { usageApi } from '@/api/usage'
|
2026-05-09 01:21:26 +08:00
|
|
|
|
import type { ImageProgress } from '@/api/requestTrace'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { usersApi } from '@/api/users'
|
|
|
|
|
|
import { meApi } from '@/api/me'
|
2026-04-19 15:17:25 +08:00
|
|
|
|
import { dashboardApi } from '@/api/dashboard'
|
2026-03-14 13:28:56 +08:00
|
|
|
|
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import {
|
|
|
|
|
|
UsageModelTable,
|
|
|
|
|
|
UsageProviderTable,
|
|
|
|
|
|
UsageApiFormatTable,
|
|
|
|
|
|
UsageRecordsTable,
|
|
|
|
|
|
ActivityHeatmapCard,
|
2025-12-11 17:52:32 +08:00
|
|
|
|
RequestDetailDrawer,
|
|
|
|
|
|
IntervalTimelineCard
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} from '@/features/usage/components'
|
|
|
|
|
|
import {
|
|
|
|
|
|
useUsageData,
|
|
|
|
|
|
getDateRangeFromPeriod
|
|
|
|
|
|
} from '@/features/usage/composables'
|
2026-04-10 20:58:01 +08:00
|
|
|
|
import { reconcileActiveRequestDiscovery } from '@/features/usage/utils/activeRequestDiscovery'
|
2026-04-23 14:42:51 +08:00
|
|
|
|
import {
|
|
|
|
|
|
hasUsageFallback,
|
|
|
|
|
|
isUsageRecordFailed,
|
2026-04-24 03:07:11 +08:00
|
|
|
|
isUsageUpstreamStream,
|
2026-05-24 18:48:50 +08:00
|
|
|
|
normalizeRequestStatus,
|
2026-04-24 03:07:11 +08:00
|
|
|
|
resolveDisplayRequestStatus,
|
2026-04-23 14:42:51 +08:00
|
|
|
|
} from '@/features/usage/utils/status'
|
2026-05-28 20:24:48 +08:00
|
|
|
|
import type { DateRangeParams, FilterStatusValue, RequestStatus, UsageRecord } from '@/features/usage/types'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
2025-12-12 20:22:15 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2026-01-04 22:42:58 +08:00
|
|
|
|
import type { ActivityHeatmap } from '@/types/activity'
|
|
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
2026-01-04 22:42:58 +08:00
|
|
|
|
const { warning } = useToast()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
|
|
|
|
|
|
|
|
// 判断是否是管理员页面
|
|
|
|
|
|
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
|
|
|
|
|
|
2026-03-14 13:28:56 +08:00
|
|
|
|
// 用量分析面板折叠状态(默认展开,持久化到 localStorage)
|
|
|
|
|
|
const statsExpanded = useLocalStorage('usage-stats-expanded', true)
|
2026-05-28 20:24:48 +08:00
|
|
|
|
const hideUnknownRecords = useLocalStorage('usage-hide-unknown-records', false)
|
2026-03-14 13:28:56 +08:00
|
|
|
|
|
2026-02-04 02:04:54 +08:00
|
|
|
|
// 时间范围选择
|
2026-03-31 19:19:04 +08:00
|
|
|
|
const timeRange = ref<DateRangeParams>(
|
2026-04-13 14:14:07 +08:00
|
|
|
|
getDateRangeFromPeriod('today')
|
2026-03-31 19:19:04 +08:00
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 分页状态
|
|
|
|
|
|
const currentPage = ref(1)
|
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
|
const pageSizeOptions = [10, 20, 50, 100]
|
|
|
|
|
|
|
2026-04-19 15:17:25 +08:00
|
|
|
|
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}小时`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 筛选状态
|
2026-01-05 19:32:57 +08:00
|
|
|
|
const filterSearch = ref('')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const filterUser = ref('__all__')
|
|
|
|
|
|
const filterModel = ref('__all__')
|
|
|
|
|
|
const filterProvider = ref('__all__')
|
2026-02-04 02:04:54 +08:00
|
|
|
|
const filterApiFormat = ref('__all__')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const filterStatus = ref<FilterStatusValue>('__all__')
|
2026-05-16 15:55:38 +08:00
|
|
|
|
const filterClientFamily = ref('__all__')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 用户列表(仅管理员页面使用)
|
|
|
|
|
|
const availableUsers = ref<UserOption[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 composables
|
|
|
|
|
|
const {
|
|
|
|
|
|
isLoadingRecords,
|
|
|
|
|
|
providerStats,
|
|
|
|
|
|
apiFormatStats,
|
|
|
|
|
|
currentRecords,
|
|
|
|
|
|
totalRecords,
|
|
|
|
|
|
enhancedModelStats,
|
|
|
|
|
|
availableModels,
|
|
|
|
|
|
availableProviders,
|
|
|
|
|
|
loadStats,
|
2026-05-31 20:29:39 +08:00
|
|
|
|
loadRecords
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} = useUsageData({ isAdminPage })
|
|
|
|
|
|
|
2026-01-04 22:42:58 +08:00
|
|
|
|
// 热力图状态
|
|
|
|
|
|
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
|
|
|
|
|
|
const isLoadingHeatmap = ref(false)
|
|
|
|
|
|
const heatmapError = ref(false)
|
2026-04-19 15:17:25 +08:00
|
|
|
|
const intervalTimelineHours = computed(() => getIntervalTimelineHours(timeRange.value))
|
|
|
|
|
|
const intervalTimelineTitle = computed(() => {
|
|
|
|
|
|
const baseTitle = isAdminPage.value ? '请求间隔时间线' : '我的请求间隔'
|
|
|
|
|
|
return `${baseTitle}(${formatIntervalTimelineWindow(intervalTimelineHours.value)})`
|
|
|
|
|
|
})
|
2026-03-18 00:12:00 +08:00
|
|
|
|
const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000
|
|
|
|
|
|
let adminAnalyticsRefreshInFlight: Promise<void> | null = null
|
|
|
|
|
|
let lastAdminAnalyticsRefreshAt = 0
|
2026-05-05 18:27:36 +08:00
|
|
|
|
let adminAnalyticsRefreshGeneration = 0
|
2026-01-04 22:42:58 +08:00
|
|
|
|
|
|
|
|
|
|
// 加载热力图数据
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 00:12:00 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 16:51:52 +08:00
|
|
|
|
async function refreshAdminAnalytics(options: { force?: boolean; preserveOnFailure?: boolean } = {}) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
if (!isAdminPage.value) return
|
|
|
|
|
|
if (!options.force && !isPageVisible.value) return
|
|
|
|
|
|
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
if (!options.force && now - lastAdminAnalyticsRefreshAt < ADMIN_ANALYTICS_REFRESH_INTERVAL) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-05 18:27:36 +08:00
|
|
|
|
if (!options.force && adminAnalyticsRefreshInFlight) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
return adminAnalyticsRefreshInFlight
|
|
|
|
|
|
}
|
2026-05-20 16:51:52 +08:00
|
|
|
|
if (!options.force) {
|
|
|
|
|
|
lastAdminAnalyticsRefreshAt = now
|
|
|
|
|
|
}
|
2026-03-18 00:12:00 +08:00
|
|
|
|
|
2026-05-05 18:27:36 +08:00
|
|
|
|
const refreshGeneration = ++adminAnalyticsRefreshGeneration
|
|
|
|
|
|
const refreshPromise = (async () => {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
let hasSuccessfulRefresh = false
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-20 16:51:52 +08:00
|
|
|
|
const hadFailure = await loadStats(getCurrentStatsFilters(), {
|
|
|
|
|
|
force: options.force,
|
|
|
|
|
|
preserveOnFailure: options.preserveOnFailure,
|
|
|
|
|
|
})
|
2026-05-05 18:27:36 +08:00
|
|
|
|
if (refreshGeneration !== adminAnalyticsRefreshGeneration) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
hasSuccessfulRefresh = !hadFailure
|
|
|
|
|
|
if (hadFailure) {
|
|
|
|
|
|
warning('统计数据加载失败,请刷新重试')
|
|
|
|
|
|
}
|
2026-03-18 00:12:00 +08:00
|
|
|
|
} catch (error) {
|
2026-05-05 18:27:36 +08:00
|
|
|
|
if (refreshGeneration !== adminAnalyticsRefreshGeneration) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-18 00:12:00 +08:00
|
|
|
|
log.error('加载统计数据失败:', error)
|
|
|
|
|
|
warning('统计数据加载失败,请刷新重试')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 18:27:36 +08:00
|
|
|
|
if (hasSuccessfulRefresh && refreshGeneration === adminAnalyticsRefreshGeneration) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
lastAdminAnalyticsRefreshAt = Date.now()
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
2026-05-05 18:27:36 +08:00
|
|
|
|
adminAnalyticsRefreshInFlight = refreshPromise
|
2026-03-18 00:12:00 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-05 18:27:36 +08:00
|
|
|
|
await refreshPromise
|
2026-03-18 00:12:00 +08:00
|
|
|
|
} finally {
|
2026-05-05 18:27:36 +08:00
|
|
|
|
if (adminAnalyticsRefreshInFlight === refreshPromise) {
|
|
|
|
|
|
adminAnalyticsRefreshInFlight = null
|
|
|
|
|
|
}
|
2026-03-18 00:12:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 16:51:52 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 20:24:48 +08:00
|
|
|
|
function isUnknownUsageLabel(value: unknown): boolean {
|
|
|
|
|
|
if (typeof value !== 'string') return false
|
|
|
|
|
|
const normalized = value.trim().toLowerCase()
|
|
|
|
|
|
return normalized === 'unknown' || normalized === 'unknow'
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-05-28 20:24:48 +08:00
|
|
|
|
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) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (filterModel.value !== '__all__') {
|
|
|
|
|
|
records = records.filter(record => record.model === filterModel.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (filterProvider.value !== '__all__') {
|
|
|
|
|
|
records = records.filter(record => record.provider === filterProvider.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 02:04:54 +08:00
|
|
|
|
if (filterApiFormat.value !== '__all__') {
|
|
|
|
|
|
records = records.filter(record =>
|
|
|
|
|
|
record.api_format?.toUpperCase() === filterApiFormat.value.toUpperCase()
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (filterStatus.value !== '__all__') {
|
|
|
|
|
|
if (filterStatus.value === 'stream') {
|
|
|
|
|
|
records = records.filter(record =>
|
2026-04-23 14:42:51 +08:00
|
|
|
|
isUsageUpstreamStream(record) && !isUsageRecordFailed(record)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
} else if (filterStatus.value === 'standard') {
|
|
|
|
|
|
records = records.filter(record =>
|
2026-04-23 14:42:51 +08:00
|
|
|
|
!isUsageUpstreamStream(record) && !isUsageRecordFailed(record)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
} else if (filterStatus.value === 'active') {
|
|
|
|
|
|
records = records.filter(record =>
|
2026-04-24 03:07:11 +08:00
|
|
|
|
resolveDisplayRequestStatus(record) === 'pending' ||
|
|
|
|
|
|
resolveDisplayRequestStatus(record) === 'streaming'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
} else if (filterStatus.value === 'failed') {
|
2026-04-10 21:33:34 +08:00
|
|
|
|
records = records.filter(record => isUsageRecordFailed(record))
|
2026-02-04 03:17:55 +08:00
|
|
|
|
} else if (filterStatus.value === 'cancelled') {
|
|
|
|
|
|
records = records.filter(record => record.status === 'cancelled')
|
2026-04-17 13:38:23 +08:00
|
|
|
|
} else if (filterStatus.value === 'has_fallback') {
|
|
|
|
|
|
records = records.filter(record => hasUsageFallback(record))
|
2026-05-16 15:55:38 +08:00
|
|
|
|
} else if (filterStatus.value === 'has_retry') {
|
|
|
|
|
|
records = records.filter(record => record.has_retry === true)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 15:55:38 +08:00
|
|
|
|
if (filterClientFamily.value !== '__all__') {
|
|
|
|
|
|
records = records.filter(record => record.client_family === filterClientFamily.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return records
|
|
|
|
|
|
}
|
2026-05-28 20:24:48 +08:00
|
|
|
|
return records
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 获取活跃请求的 ID 列表
|
|
|
|
|
|
const activeRequestIds = computed(() => {
|
|
|
|
|
|
return currentRecords.value
|
2026-04-24 03:07:11 +08:00
|
|
|
|
.filter((record) => {
|
|
|
|
|
|
const displayStatus = resolveDisplayRequestStatus(record)
|
|
|
|
|
|
return displayStatus === 'pending' || displayStatus === 'streaming'
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.map(record => record.id)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否有活跃请求
|
|
|
|
|
|
const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
|
|
|
|
|
|
|
|
|
|
|
// 自动刷新定时器
|
2026-03-09 22:57:23 +08:00
|
|
|
|
let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
2026-04-10 20:58:01 +08:00
|
|
|
|
let activeDiscoveryTimer: ReturnType<typeof setTimeout> | null = null
|
2025-12-19 17:31:15 +08:00
|
|
|
|
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
2026-03-03 22:04:40 +08:00
|
|
|
|
let refreshInFlight: Promise<void> | null = null
|
2025-12-19 17:31:15 +08:00
|
|
|
|
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
2026-04-10 20:58:01 +08:00
|
|
|
|
const ACTIVE_DISCOVERY_HOT_INTERVAL = 1000 // 有活跃请求时 1 秒扫描一次
|
|
|
|
|
|
const ACTIVE_DISCOVERY_IDLE_INTERVAL = 5000 // 空闲时降频,避免后台持续刷日志
|
2026-03-04 02:24:46 +08:00
|
|
|
|
const GLOBAL_AUTO_REFRESH_INTERVAL = 3000 // 3秒刷新一次(全局自动刷新)
|
2026-03-04 10:00:38 +08:00
|
|
|
|
const globalAutoRefresh = ref(false) // 全局自动刷新开关(默认关闭)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
2026-02-04 03:17:55 +08:00
|
|
|
|
|
|
|
|
|
|
let pollInFlight = false
|
2026-04-10 20:58:01 +08:00
|
|
|
|
let activeDiscoveryInFlight = false
|
|
|
|
|
|
const discoveredActiveRequestIds = new Set<string>()
|
|
|
|
|
|
|
|
|
|
|
|
async function loadActiveRequestUpdates(ids?: string[]) {
|
|
|
|
|
|
if (isAdminPage.value) {
|
2026-05-31 20:29:39 +08:00
|
|
|
|
return usageApi.getActiveRequests(ids, timeRange.value)
|
2026-04-10 20:58:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
const idsParam = ids?.length ? ids.join(',') : undefined
|
2026-05-31 20:29:39 +08:00
|
|
|
|
return meApi.getActiveRequests(idsParam)
|
2026-04-10 20:58:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
async function pollActiveRequests() {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (!isPageVisible.value) return
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (!hasActiveRequests.value) return
|
2026-02-04 03:17:55 +08:00
|
|
|
|
if (pollInFlight) return
|
|
|
|
|
|
pollInFlight = true
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-10 20:58:01 +08:00
|
|
|
|
const { requests } = await loadActiveRequestUpdates(activeRequestIds.value)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
const recordMap = new Map(currentRecords.value.map(record => [record.id, record]))
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
for (const update of requests) {
|
2026-03-09 22:57:23 +08:00
|
|
|
|
const record = recordMap.get(update.id)
|
2026-04-16 11:57:42 +08:00
|
|
|
|
if (!record) continue
|
2026-01-05 10:31:34 +08:00
|
|
|
|
|
2026-02-04 03:17:55 +08:00
|
|
|
|
// 状态只允许单向推进,避免异步响应回退(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 shouldApply = newRank >= currentRank
|
2026-04-24 03:07:11 +08:00
|
|
|
|
const updateHasFailureSignal =
|
|
|
|
|
|
(typeof update.status_code === 'number' && update.status_code >= 400) ||
|
2026-05-09 01:21:26 +08:00
|
|
|
|
(typeof update.error_message === 'string' && update.error_message.trim().length > 0) ||
|
|
|
|
|
|
update.image_progress?.phase === 'failed'
|
2026-04-24 03:07:11 +08:00
|
|
|
|
const shouldApplyData = shouldApply || updateHasFailureSignal
|
2026-02-04 03:17:55 +08:00
|
|
|
|
|
|
|
|
|
|
if (shouldApply && record.status !== update.status) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
record.status = update.status
|
2026-01-05 10:31:34 +08:00
|
|
|
|
}
|
2026-05-09 01:21:26 +08:00
|
|
|
|
if ('image_progress' in update) {
|
|
|
|
|
|
record.image_progress = update.image_progress ?? null
|
|
|
|
|
|
}
|
2026-04-24 03:07:11 +08:00
|
|
|
|
if (shouldApplyData) {
|
2026-02-04 03:17:55 +08:00
|
|
|
|
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
|
|
|
|
|
record.input_tokens = update.input_tokens
|
2026-04-10 17:44:55 +08:00
|
|
|
|
record.effective_input_tokens = update.effective_input_tokens ?? record.effective_input_tokens
|
2026-02-04 03:17:55 +08:00
|
|
|
|
record.output_tokens = update.output_tokens
|
|
|
|
|
|
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
2026-04-19 02:46:28 +08:00
|
|
|
|
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
|
2026-02-04 03:17:55 +08:00
|
|
|
|
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
|
|
|
|
|
|
record.response_time_ms = update.response_time_ms ?? undefined
|
|
|
|
|
|
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
2026-06-10 09:16:15 +08:00
|
|
|
|
if ('updated_at' in update) {
|
|
|
|
|
|
record.updated_at = typeof update.updated_at === 'string' ? update.updated_at : null
|
|
|
|
|
|
}
|
|
|
|
|
|
if ('response_time_updated_at' in update) {
|
|
|
|
|
|
record.response_time_updated_at =
|
|
|
|
|
|
typeof update.response_time_updated_at === 'string'
|
|
|
|
|
|
? update.response_time_updated_at
|
|
|
|
|
|
: null
|
|
|
|
|
|
}
|
2026-04-24 03:07:11 +08:00
|
|
|
|
record.status_code = update.status_code ?? undefined
|
|
|
|
|
|
record.error_message = update.error_message ?? undefined
|
2026-04-23 14:42:51 +08:00
|
|
|
|
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
|
2026-04-23 21:53:50 +08:00
|
|
|
|
record.upstream_is_stream = update.is_stream
|
2026-04-23 14:42:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-04-23 21:53:50 +08:00
|
|
|
|
record.client_is_stream = update.client_requested_stream
|
2026-04-23 14:42:51 +08:00
|
|
|
|
}
|
2026-02-04 03:17:55 +08:00
|
|
|
|
// 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
|
2026-04-17 13:38:23 +08:00
|
|
|
|
if (typeof update.has_fallback === 'boolean') {
|
|
|
|
|
|
record.has_fallback = record.has_fallback === true || update.has_fallback
|
|
|
|
|
|
}
|
2026-02-04 03:17:55 +08:00
|
|
|
|
// 模型映射:streaming 时已可确定
|
|
|
|
|
|
if ('target_model' in update && (typeof update.target_model === 'string' || update.target_model === null)) {
|
|
|
|
|
|
record.target_model = update.target_model
|
|
|
|
|
|
}
|
2026-05-27 00:36:52 +08:00
|
|
|
|
if ('reasoning_effort' in update) {
|
|
|
|
|
|
record.reasoning_effort = typeof update.reasoning_effort === 'string'
|
|
|
|
|
|
? update.reasoning_effort
|
|
|
|
|
|
: null
|
|
|
|
|
|
}
|
|
|
|
|
|
if ('service_tier' in update) {
|
|
|
|
|
|
record.service_tier = typeof update.service_tier === 'string'
|
|
|
|
|
|
? update.service_tier
|
|
|
|
|
|
: null
|
|
|
|
|
|
}
|
2026-02-04 03:17:55 +08:00
|
|
|
|
// 管理员接口返回额外字段
|
2026-05-06 09:09:55 +08:00
|
|
|
|
// 只有当返回的 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
|
|
|
|
|
|
}
|
2026-02-04 03:17:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ('api_key_name' in update) {
|
|
|
|
|
|
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
|
|
|
|
|
|
}
|
2026-04-05 20:23:16 +08:00
|
|
|
|
if ('provider_key_name' in update) {
|
|
|
|
|
|
record.provider_key_name = typeof update.provider_key_name === 'string'
|
|
|
|
|
|
? update.provider_key_name
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
2026-05-22 15:28:19 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 11:57:42 +08:00
|
|
|
|
// 不再因活跃请求完成而全表刷新,字段已在上方就地更新
|
|
|
|
|
|
// 未知请求(shouldRefresh 由 !record 触发)理论上不应出现在已知 ID 轮询中,忽略即可
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} catch (error) {
|
2025-12-12 20:22:15 +08:00
|
|
|
|
log.error('轮询活跃请求状态失败:', error)
|
2026-02-04 03:17:55 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
pollInFlight = false
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:58:01 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
function scheduleNextAutoRefresh() {
|
|
|
|
|
|
if (autoRefreshTimer) return
|
|
|
|
|
|
if (!isPageVisible.value || !hasActiveRequests.value) return
|
|
|
|
|
|
autoRefreshTimer = setTimeout(async () => {
|
|
|
|
|
|
autoRefreshTimer = null
|
|
|
|
|
|
await pollActiveRequests()
|
|
|
|
|
|
scheduleNextAutoRefresh()
|
|
|
|
|
|
}, AUTO_REFRESH_INTERVAL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:58:01 +08:00
|
|
|
|
function scheduleNextActiveDiscovery() {
|
|
|
|
|
|
if (activeDiscoveryTimer) return
|
|
|
|
|
|
if (!isPageVisible.value) return
|
2026-04-16 11:57:42 +08:00
|
|
|
|
if (!globalAutoRefresh.value) return
|
2026-04-10 20:58:01 +08:00
|
|
|
|
const interval = hasActiveRequests.value || discoveredActiveRequestIds.size > 0
|
|
|
|
|
|
? ACTIVE_DISCOVERY_HOT_INTERVAL
|
|
|
|
|
|
: ACTIVE_DISCOVERY_IDLE_INTERVAL
|
|
|
|
|
|
activeDiscoveryTimer = setTimeout(async () => {
|
|
|
|
|
|
activeDiscoveryTimer = null
|
|
|
|
|
|
await discoverActiveRequests()
|
|
|
|
|
|
scheduleNextActiveDiscovery()
|
|
|
|
|
|
}, interval)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 启动自动刷新
|
|
|
|
|
|
function startAutoRefresh() {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (!isPageVisible.value) return
|
2026-03-09 22:57:23 +08:00
|
|
|
|
scheduleNextAutoRefresh()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:58:01 +08:00
|
|
|
|
function startActiveDiscovery() {
|
|
|
|
|
|
if (!isPageVisible.value) return
|
2026-04-16 11:57:42 +08:00
|
|
|
|
if (!globalAutoRefresh.value) return
|
2026-04-10 20:58:01 +08:00
|
|
|
|
if (activeDiscoveryTimer || activeDiscoveryInFlight) return
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
|
await discoverActiveRequests()
|
|
|
|
|
|
scheduleNextActiveDiscovery()
|
|
|
|
|
|
})()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 停止自动刷新
|
|
|
|
|
|
function stopAutoRefresh() {
|
|
|
|
|
|
if (autoRefreshTimer) {
|
2026-03-09 22:57:23 +08:00
|
|
|
|
clearTimeout(autoRefreshTimer)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
autoRefreshTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:58:01 +08:00
|
|
|
|
function stopActiveDiscovery() {
|
|
|
|
|
|
if (activeDiscoveryTimer) {
|
|
|
|
|
|
clearTimeout(activeDiscoveryTimer)
|
|
|
|
|
|
activeDiscoveryTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 21:33:34 +08:00
|
|
|
|
// 监听活跃请求状态,已显示的活跃行始终自刷新到终态。
|
|
|
|
|
|
// “自动刷新”开关只控制全局 3 秒刷新和新活跃请求发现。
|
2025-12-10 20:52:44 +08:00
|
|
|
|
watch(hasActiveRequests, (hasActive) => {
|
2026-04-10 21:33:34 +08:00
|
|
|
|
if (hasActive && isPageVisible.value) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
startAutoRefresh()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stopAutoRefresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
2025-12-19 17:31:15 +08:00
|
|
|
|
// 启动全局自动刷新
|
|
|
|
|
|
function startGlobalAutoRefresh() {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (!isPageVisible.value) return
|
2025-12-19 17:31:15 +08:00
|
|
|
|
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) {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (isPageVisible.value) {
|
|
|
|
|
|
refreshData() // 立即刷新一次
|
2026-04-10 20:58:01 +08:00
|
|
|
|
startActiveDiscovery()
|
|
|
|
|
|
if (hasActiveRequests.value) {
|
|
|
|
|
|
startAutoRefresh()
|
|
|
|
|
|
}
|
2026-03-03 22:04:40 +08:00
|
|
|
|
}
|
2025-12-19 17:31:15 +08:00
|
|
|
|
startGlobalAutoRefresh()
|
|
|
|
|
|
} else {
|
2026-04-10 20:58:01 +08:00
|
|
|
|
stopActiveDiscovery()
|
2025-12-19 17:31:15 +08:00
|
|
|
|
stopGlobalAutoRefresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 20:24:48 +08:00
|
|
|
|
async function handleHideUnknownRecordsChange(value: boolean) {
|
|
|
|
|
|
hideUnknownRecords.value = value
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
if (isAdminPage.value) {
|
|
|
|
|
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
function handleVisibilityChange() {
|
|
|
|
|
|
isPageVisible.value = !document.hidden
|
|
|
|
|
|
if (!isPageVisible.value) {
|
|
|
|
|
|
stopAutoRefresh()
|
2026-04-10 20:58:01 +08:00
|
|
|
|
stopActiveDiscovery()
|
2026-03-03 22:04:40 +08:00
|
|
|
|
stopGlobalAutoRefresh()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-10 21:33:34 +08:00
|
|
|
|
if (hasActiveRequests.value) {
|
|
|
|
|
|
startAutoRefresh()
|
|
|
|
|
|
}
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (globalAutoRefresh.value) {
|
2026-04-10 20:58:01 +08:00
|
|
|
|
startActiveDiscovery()
|
2026-03-03 22:04:40 +08:00
|
|
|
|
refreshData()
|
|
|
|
|
|
startGlobalAutoRefresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 组件卸载时清理定时器
|
|
|
|
|
|
onUnmounted(() => {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
stopAutoRefresh()
|
2026-04-10 20:58:01 +08:00
|
|
|
|
stopActiveDiscovery()
|
2025-12-19 17:31:15 +08:00
|
|
|
|
stopGlobalAutoRefresh()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-01 18:20:42 +08:00
|
|
|
|
// 用户页面的前端分页(后端一次性返回所有记录,前端分页+筛选)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const paginatedRecords = computed(() => {
|
|
|
|
|
|
if (!isAdminPage.value) {
|
|
|
|
|
|
const start = (currentPage.value - 1) * pageSize.value
|
|
|
|
|
|
const end = start + pageSize.value
|
|
|
|
|
|
return filteredRecords.value.slice(start, end)
|
|
|
|
|
|
}
|
2026-05-28 20:24:48 +08:00
|
|
|
|
return filteredRecords.value
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-01 18:20:42 +08:00
|
|
|
|
// 用户页面使用前端筛选后的总数,管理员页面使用后端返回的总数
|
|
|
|
|
|
const effectiveTotalRecords = computed(() => {
|
|
|
|
|
|
if (!isAdminPage.value) {
|
|
|
|
|
|
return filteredRecords.value.length
|
|
|
|
|
|
}
|
|
|
|
|
|
return totalRecords.value
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 显示的记录
|
|
|
|
|
|
const displayRecords = computed(() => paginatedRecords.value)
|
|
|
|
|
|
|
2026-05-16 15:55:38 +08:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 详情弹窗状态
|
|
|
|
|
|
const detailModalOpen = ref(false)
|
|
|
|
|
|
const selectedRequestId = ref<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化加载
|
|
|
|
|
|
onMounted(async () => {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
|
|
|
|
|
2026-02-28 15:09:21 +08:00
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
// 管理员页面优先加载记录,统计面板在后台顺序刷新,避免瞬时并发打满后端。
|
|
|
|
|
|
await loadRecords(
|
2026-03-01 18:20:42 +08:00
|
|
|
|
{ page: currentPage.value, pageSize: pageSize.value },
|
2026-03-18 00:12:00 +08:00
|
|
|
|
getCurrentFilters(),
|
|
|
|
|
|
timeRange.value
|
2026-02-28 15:09:21 +08:00
|
|
|
|
)
|
2026-03-18 00:12:00 +08:00
|
|
|
|
void (async () => {
|
2026-05-20 16:51:52 +08:00
|
|
|
|
await refreshAdminAnalytics({ force: true, preserveOnFailure: false })
|
2026-04-19 15:17:25 +08:00
|
|
|
|
await loadHeatmapData()
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadAdminUsers()
|
|
|
|
|
|
})()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 用户页面:loadStats 已包含记录加载,不需要单独调用 loadRecords
|
|
|
|
|
|
await Promise.allSettled([
|
|
|
|
|
|
loadStats(timeRange.value).catch(err => {
|
|
|
|
|
|
log.error('加载统计数据失败:', err)
|
|
|
|
|
|
warning('统计数据加载失败,请刷新重试')
|
|
|
|
|
|
}),
|
|
|
|
|
|
loadHeatmapData().catch(err => {
|
|
|
|
|
|
log.error('加载热力图数据失败:', err)
|
|
|
|
|
|
})
|
|
|
|
|
|
])
|
2026-01-04 22:42:58 +08:00
|
|
|
|
}
|
2026-03-04 02:24:46 +08:00
|
|
|
|
|
2026-04-10 20:58:01 +08:00
|
|
|
|
if (globalAutoRefresh.value && isPageVisible.value) {
|
|
|
|
|
|
startActiveDiscovery()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 02:24:46 +08:00
|
|
|
|
if (globalAutoRefresh.value && isPageVisible.value) {
|
|
|
|
|
|
startGlobalAutoRefresh()
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-04 02:04:54 +08:00
|
|
|
|
// 处理时间范围变化
|
|
|
|
|
|
async function handleTimeRangeChange(value: DateRangeParams) {
|
|
|
|
|
|
timeRange.value = value
|
|
|
|
|
|
currentPage.value = 1 // 重置到第一页
|
2026-03-01 18:20:42 +08:00
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
2026-05-20 16:51:52 +08:00
|
|
|
|
await refreshAdminAnalyticsForSelectionChange()
|
2026-03-18 00:12:00 +08:00
|
|
|
|
return
|
2026-03-01 18:20:42 +08:00
|
|
|
|
}
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadStats(timeRange.value)
|
2026-03-01 18:20:42 +08:00
|
|
|
|
// 用户页面:loadStats 已包含记录加载
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理分页变化
|
|
|
|
|
|
async function handlePageChange(page: number) {
|
|
|
|
|
|
currentPage.value = page
|
2026-03-01 18:20:42 +08:00
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
2026-03-01 18:20:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 用户页面使用前端分页,无需重新请求
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理每页大小变化
|
|
|
|
|
|
async function handlePageSizeChange(size: number) {
|
|
|
|
|
|
pageSize.value = size
|
|
|
|
|
|
currentPage.value = 1 // 重置到第一页
|
2026-03-01 18:20:42 +08:00
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters(), timeRange.value)
|
2026-03-01 18:20:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 用户页面使用前端分页,无需重新请求
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前筛选参数
|
|
|
|
|
|
function getCurrentFilters() {
|
|
|
|
|
|
return {
|
2026-01-05 19:32:57 +08:00
|
|
|
|
search: filterSearch.value.trim() || undefined,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
|
|
|
|
|
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
|
|
|
|
|
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
2026-02-04 02:04:54 +08:00
|
|
|
|
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
|
2026-05-16 15:55:38 +08:00
|
|
|
|
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined,
|
2026-05-28 20:24:48 +08:00
|
|
|
|
client_family: filterClientFamily.value !== '__all__' ? filterClientFamily.value : undefined,
|
|
|
|
|
|
hideUnknownRecords: hideUnknownRecords.value || undefined
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理筛选变化
|
2026-01-05 19:32:57 +08:00
|
|
|
|
async function handleFilterSearchChange(value: string) {
|
|
|
|
|
|
filterSearch.value = value
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
|
2026-03-01 18:20:42 +08:00
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
2026-03-01 18:20:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 用户页面:search 需要重新从后端拉取数据(后端支持 search 参数)
|
|
|
|
|
|
// 但通过 filteredRecords 做前端过滤已覆盖,无需额外请求
|
2026-01-05 19:32:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
async function handleFilterUserChange(value: string) {
|
|
|
|
|
|
filterUser.value = value
|
|
|
|
|
|
currentPage.value = 1 // 重置到第一页
|
|
|
|
|
|
|
|
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
2026-05-20 16:51:52 +08:00
|
|
|
|
await refreshAdminAnalyticsForSelectionChange()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleFilterModelChange(value: string) {
|
|
|
|
|
|
filterModel.value = value
|
|
|
|
|
|
currentPage.value = 1 // 重置到第一页
|
|
|
|
|
|
|
|
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
2026-05-20 16:51:52 +08:00
|
|
|
|
await refreshAdminAnalyticsForSelectionChange()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleFilterProviderChange(value: string) {
|
|
|
|
|
|
filterProvider.value = value
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
|
|
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
2026-05-20 16:51:52 +08:00
|
|
|
|
await refreshAdminAnalyticsForSelectionChange()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 02:04:54 +08:00
|
|
|
|
async function handleFilterApiFormatChange(value: string) {
|
|
|
|
|
|
filterApiFormat.value = value
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
|
|
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
2026-02-04 02:04:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
async function handleFilterStatusChange(value: string) {
|
|
|
|
|
|
filterStatus.value = value as FilterStatusValue
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
|
|
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 15:55:38 +08:00
|
|
|
|
async function handleFilterClientFamilyChange(value: string) {
|
|
|
|
|
|
filterClientFamily.value = value
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
|
|
|
|
|
|
if (isAdminPage.value) {
|
|
|
|
|
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 刷新数据
|
|
|
|
|
|
async function refreshData() {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (!isPageVisible.value) return
|
|
|
|
|
|
if (refreshInFlight) return refreshInFlight
|
|
|
|
|
|
|
|
|
|
|
|
refreshInFlight = (async () => {
|
|
|
|
|
|
if (isAdminPage.value) {
|
2026-03-18 00:12:00 +08:00
|
|
|
|
await loadRecords(
|
|
|
|
|
|
{ page: currentPage.value, pageSize: pageSize.value },
|
|
|
|
|
|
getCurrentFilters(),
|
|
|
|
|
|
timeRange.value
|
|
|
|
|
|
)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await loadStats(timeRange.value)
|
|
|
|
|
|
// 用户页面:loadStats 已包含记录加载
|
|
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await refreshInFlight
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
refreshInFlight = null
|
2026-03-01 18:20:42 +08:00
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 16:51:52 +08:00
|
|
|
|
async function handleManualRefresh() {
|
|
|
|
|
|
if (!isPageVisible.value) return
|
|
|
|
|
|
await refreshData()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 显示请求详情
|
|
|
|
|
|
function showRequestDetail(id: string) {
|
|
|
|
|
|
if (!isAdminPage.value) return
|
|
|
|
|
|
selectedRequestId.value = id
|
|
|
|
|
|
detailModalOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
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
|
|
|
|
|
|
responseTimeMs?: number | null
|
|
|
|
|
|
imageProgress?: ImageProgress | null
|
|
|
|
|
|
errorMessage?: string | null
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const record = currentRecords.value.find(record => record.id === update.id)
|
|
|
|
|
|
if (!record) return
|
|
|
|
|
|
|
2026-05-24 18:48:50 +08:00
|
|
|
|
const nextStatus = resolveDetailUpdateStatus(update)
|
|
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
const statusPriority: Record<RequestStatus, number> = {
|
|
|
|
|
|
pending: 0,
|
|
|
|
|
|
streaming: 1,
|
|
|
|
|
|
completed: 2,
|
|
|
|
|
|
failed: 2,
|
|
|
|
|
|
cancelled: 2,
|
|
|
|
|
|
}
|
2026-05-24 18:48:50 +08:00
|
|
|
|
if (nextStatus) {
|
2026-05-09 01:21:26 +08:00
|
|
|
|
const currentRank = record.status ? statusPriority[record.status] : 0
|
2026-05-24 18:48:50 +08:00
|
|
|
|
const nextRank = statusPriority[nextStatus]
|
2026-05-09 01:21:26 +08:00
|
|
|
|
if (nextRank >= currentRank) {
|
2026-05-24 18:48:50 +08:00
|
|
|
|
record.status = nextStatus
|
2026-05-09 01:21:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ('statusCode' in update) {
|
|
|
|
|
|
record.status_code = update.statusCode ?? undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
if ('responseTimeMs' in update && update.responseTimeMs != null) {
|
|
|
|
|
|
record.response_time_ms = update.responseTimeMs
|
|
|
|
|
|
}
|
|
|
|
|
|
if ('imageProgress' in update) {
|
|
|
|
|
|
const nextProgress = update.imageProgress ?? null
|
|
|
|
|
|
if (!sameImageProgress(record.image_progress, nextProgress)) {
|
|
|
|
|
|
record.image_progress = nextProgress
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ('errorMessage' in update) {
|
|
|
|
|
|
record.error_message = update.errorMessage ?? undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:48:50 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 15:17:25 +08:00
|
|
|
|
function prefetchRequestDetail(id: string) {
|
|
|
|
|
|
if (!isAdminPage.value) return
|
|
|
|
|
|
void dashboardApi.prefetchRequestDetail(id).catch(error => {
|
|
|
|
|
|
log.debug('预取请求详情失败', error)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
</style>
|