mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
perf: 全栈查询优化、前端缓存去重与页面可见性优化
后端: - SQL count 查询统一改用 func.count() 子查询替代 query.count() - Dashboard/Audit 等页面多次独立查询合并为单次聚合查询 - Provider summary 列表改为批量查询消除 N+1 问题 - DailyStats 逐天循环查询改为 CASE 分桶单次查询 - 使用 load_only() 减少不必要的列加载 - cache_decorator 支持嵌套属性路径解析(dotted vary_by) - 多个管理/公共端点新增 @cache_result 缓存装饰 前端: - cache.ts 新增 in-flight 请求复用、dedupedRequest、buildCacheKey - 大量 API 调用添加前端缓存或去重 - 多个页面定时器在标签页隐藏时暂停、可见时恢复 - Auth 检查从 setInterval 改为 storage + visibilitychange 事件驱动 - 请求竞态防护(requestId 模式) 数据库: - Usage 表新增 idx_usage_status_user_created 复合索引
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
|
||||
// LDAP 配置导出结构
|
||||
export interface LDAPConfigExport {
|
||||
@@ -743,10 +744,17 @@ export const adminApi = {
|
||||
include_inactive?: boolean
|
||||
exclude_admin?: boolean
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getLeaderboardApiKeys(params?: {
|
||||
@@ -764,10 +772,17 @@ export const adminApi = {
|
||||
include_inactive?: boolean
|
||||
exclude_admin?: boolean
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:api-keys', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getLeaderboardModels(params?: {
|
||||
@@ -783,10 +798,17 @@ export const adminApi = {
|
||||
provider_name?: string
|
||||
model?: string
|
||||
}): Promise<LeaderboardResponse> {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:models', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getCostForecast(params?: {
|
||||
@@ -798,10 +820,17 @@ export const adminApi = {
|
||||
days?: number
|
||||
forecast_days?: number
|
||||
}): Promise<CostForecastResponse> {
|
||||
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('admin:stats:cost:forecast', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
30 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getCostSavings(params?: {
|
||||
@@ -813,15 +842,28 @@ export const adminApi = {
|
||||
provider_name?: string
|
||||
model?: string
|
||||
}): Promise<CostSavingsResponse> {
|
||||
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('admin:stats:cost:savings', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
30 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getQuotaUsage(): Promise<QuotaUsageResponse> {
|
||||
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
|
||||
return response.data
|
||||
return cachedRequest(
|
||||
'admin:stats:providers:quota-usage',
|
||||
async () => {
|
||||
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
|
||||
return response.data
|
||||
},
|
||||
30 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getPercentiles(params?: {
|
||||
@@ -831,10 +873,17 @@ export const adminApi = {
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<PercentileItem[]> {
|
||||
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('admin:stats:performance:percentiles', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getErrorDistribution(params?: {
|
||||
@@ -844,10 +893,17 @@ export const adminApi = {
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
}): Promise<ErrorDistributionResponse> {
|
||||
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('admin:stats:errors:distribution', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
async getComparison(params: {
|
||||
@@ -882,8 +938,15 @@ export const adminApi = {
|
||||
model?: string
|
||||
provider_name?: string
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('admin:stats:time-series', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import api from './client'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
|
||||
export interface CacheStats {
|
||||
scheduler: string
|
||||
@@ -314,8 +315,15 @@ export const cacheAnalysisApi = {
|
||||
user_id?: string
|
||||
include_user_info?: boolean
|
||||
}): Promise<IntervalTimelineResponse> {
|
||||
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('cache-affinity:interval-timeline', params as Record<string, unknown> | undefined)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
|
||||
return response.data
|
||||
},
|
||||
30000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
|
||||
export interface DashboardStat {
|
||||
name: string
|
||||
@@ -289,8 +290,15 @@ export interface TimeRangeParams {
|
||||
export const dashboardApi = {
|
||||
// 获取仪表盘统计数据
|
||||
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('dashboard:stats', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
|
||||
return response.data
|
||||
},
|
||||
10 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
// 获取最近的请求记录
|
||||
@@ -303,8 +311,14 @@ export const dashboardApi = {
|
||||
|
||||
// 获取提供商状态
|
||||
async getProviderStatus(): Promise<ProviderStatus[]> {
|
||||
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
|
||||
return response.data.providers
|
||||
return cachedRequest(
|
||||
'dashboard:provider-status',
|
||||
async () => {
|
||||
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
|
||||
return response.data.providers
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
// 获取请求详情
|
||||
@@ -316,10 +330,17 @@ export const dashboardApi = {
|
||||
|
||||
// 获取每日统计数据
|
||||
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
||||
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
20 * 1000
|
||||
)
|
||||
},
|
||||
|
||||
// 获取 cURL 命令数据(含明文 API Key)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import client from '../client'
|
||||
import { dedupedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type {
|
||||
GlobalModelCreate,
|
||||
GlobalModelUpdate,
|
||||
@@ -27,16 +28,21 @@ export async function getGlobalModels(params?: {
|
||||
is_active?: boolean
|
||||
search?: string
|
||||
}): Promise<GlobalModelListResponse> {
|
||||
const response = await client.get('/api/admin/models/global', { params })
|
||||
return response.data
|
||||
const key = buildCacheKey('global-models:list', params as Record<string, unknown> | undefined)
|
||||
return dedupedRequest(key, async () => {
|
||||
const response = await client.get('/api/admin/models/global', { params })
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 GlobalModel 详情
|
||||
*/
|
||||
export async function getGlobalModel(id: string): Promise<GlobalModelWithStats> {
|
||||
const response = await client.get(`/api/admin/models/global/${id}`)
|
||||
return response.data
|
||||
return dedupedRequest(`global-models:detail:${id}`, async () => {
|
||||
const response = await client.get(`/api/admin/models/global/${id}`)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,10 +118,12 @@ export async function getGlobalModelProviders(globalModelId: string): Promise<{
|
||||
providers: ModelCatalogProviderDetail[]
|
||||
total: number
|
||||
}> {
|
||||
const response = await client.get(
|
||||
`/api/admin/models/global/${globalModelId}/providers`
|
||||
)
|
||||
return response.data
|
||||
return dedupedRequest(`global-models:providers:${globalModelId}`, async () => {
|
||||
const response = await client.get(
|
||||
`/api/admin/models/global/${globalModelId}/providers`
|
||||
)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import client from '../client'
|
||||
import { dedupedRequest } from '@/utils/cache'
|
||||
import type { AllowedModels, ProxyConfig } from './types/provider'
|
||||
|
||||
export interface PoolKeyStatus {
|
||||
@@ -175,16 +176,21 @@ export interface PoolBatchAction {
|
||||
}
|
||||
|
||||
export async function getPoolOverview(): Promise<PoolOverviewResponse> {
|
||||
const response = await client.get('/api/admin/pool/overview')
|
||||
return response.data
|
||||
return dedupedRequest('pool:overview', async () => {
|
||||
const response = await client.get<PoolOverviewResponse>('/api/admin/pool/overview')
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
export async function listPoolKeys(
|
||||
providerId: string,
|
||||
params: PoolKeysQuery = {},
|
||||
): Promise<PoolKeysPageResponse> {
|
||||
const response = await client.get(`/api/admin/pool/${providerId}/keys`, { params })
|
||||
return response.data
|
||||
const key = `pool:keys:${providerId}|${params.page ?? ''}|${params.page_size ?? ''}|${params.search ?? ''}|${params.status ?? ''}`
|
||||
return dedupedRequest(key, async () => {
|
||||
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params })
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
export async function batchActionPoolKeys(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import client from '../client'
|
||||
import { dedupedRequest } from '@/utils/cache'
|
||||
import type {
|
||||
ClaudeCodeAdvancedConfig,
|
||||
FailoverRulesConfig,
|
||||
@@ -11,16 +12,20 @@ import type {
|
||||
* 获取 Providers 摘要(包含 Endpoints 统计)
|
||||
*/
|
||||
export async function getProvidersSummary(): Promise<ProviderWithEndpointsSummary[]> {
|
||||
const response = await client.get('/api/admin/providers/summary')
|
||||
return response.data
|
||||
return dedupedRequest('providers:summary', async () => {
|
||||
const response = await client.get<ProviderWithEndpointsSummary[]>('/api/admin/providers/summary')
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 Provider 的详细信息
|
||||
*/
|
||||
export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> {
|
||||
const response = await client.get(`/api/admin/providers/${providerId}/summary`)
|
||||
return response.data
|
||||
return dedupedRequest(`providers:detail:${providerId}`, async () => {
|
||||
const response = await client.get<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}/summary`)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,6 +219,8 @@ export interface ProviderMappingPreviewResponse {
|
||||
export async function getProviderMappingPreview(
|
||||
providerId: string
|
||||
): Promise<ProviderMappingPreviewResponse> {
|
||||
const response = await client.get(`/api/admin/providers/${providerId}/mapping-preview`)
|
||||
return response.data
|
||||
return dedupedRequest(`providers:mapping-preview:${providerId}`, async () => {
|
||||
const response = await client.get<ProviderMappingPreviewResponse>(`/api/admin/providers/${providerId}/mapping-preview`)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiClient from './client'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
import type { TieredPricingConfig } from './endpoints/types'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
|
||||
export interface Profile {
|
||||
id: string // UUID
|
||||
@@ -328,8 +329,15 @@ export const meApi = {
|
||||
points: Array<{ x: string; y: number; model?: string }>
|
||||
models?: string[]
|
||||
}> {
|
||||
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
||||
return response.data
|
||||
const cacheKey = buildCacheKey('me:interval-timeline', params as Record<string, unknown> | undefined)
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
||||
return response.data
|
||||
},
|
||||
30000
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -337,7 +345,13 @@ export const meApi = {
|
||||
* 后端已缓存5分钟
|
||||
*/
|
||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
||||
return response.data
|
||||
return cachedRequest(
|
||||
'me-activity-heatmap',
|
||||
async () => {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
||||
return response.data
|
||||
},
|
||||
60000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest } from '@/utils/cache'
|
||||
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
|
||||
export interface UsageRecord {
|
||||
@@ -187,8 +187,11 @@ export const usageApi = {
|
||||
limit: number
|
||||
offset: number
|
||||
}> {
|
||||
const response = await apiClient.get('/api/admin/usage/records', { params })
|
||||
return response.data
|
||||
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
|
||||
return dedupedRequest(key, async () => {
|
||||
const response = await apiClient.get('/api/admin/usage/records', { params })
|
||||
return response.data
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -226,7 +229,13 @@ export const usageApi = {
|
||||
* 后端已缓存5分钟
|
||||
*/
|
||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
|
||||
return response.data
|
||||
return cachedRequest(
|
||||
'admin-usage-activity-heatmap',
|
||||
async () => {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
|
||||
return response.data
|
||||
},
|
||||
60000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1132,6 +1132,8 @@ const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
||||
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
|
||||
const providerModels = ref<Model[]>([]) // Provider 级别的 models
|
||||
const providerMappingPreview = ref<ProviderMappingPreviewResponse | null>(null) // 映射预览
|
||||
let providerLoadRequestId = 0
|
||||
let endpointsLoadRequestId = 0
|
||||
|
||||
// 系统级格式转换配置
|
||||
const systemFormatConversionEnabled = ref(false)
|
||||
@@ -1281,12 +1283,19 @@ watch(
|
||||
}
|
||||
void autoRefreshQuotaInBackground()
|
||||
} else if (!newOpen && oldOpen) {
|
||||
// 使在途请求失效,避免关闭后旧响应回写
|
||||
providerLoadRequestId += 1
|
||||
endpointsLoadRequestId += 1
|
||||
|
||||
// 停止倒计时定时器
|
||||
stopCountdownTimer()
|
||||
// 重置所有状态
|
||||
loading.value = false
|
||||
provider.value = null
|
||||
endpoints.value = []
|
||||
providerKeys.value = [] // 清空 Provider 级别的 keys
|
||||
providerModels.value = []
|
||||
providerMappingPreview.value = null
|
||||
|
||||
// 重置分页状态
|
||||
resetKeysPagination()
|
||||
@@ -2581,6 +2590,7 @@ async function loadSystemFormatConversionConfig() {
|
||||
// 加载 Provider 信息
|
||||
async function loadProvider() {
|
||||
if (!props.providerId) return
|
||||
const requestId = ++providerLoadRequestId
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -2589,21 +2599,26 @@ async function loadProvider() {
|
||||
getProvider(props.providerId),
|
||||
loadSystemFormatConversionConfig(),
|
||||
])
|
||||
if (requestId !== providerLoadRequestId) return
|
||||
provider.value = providerData
|
||||
|
||||
if (!provider.value) {
|
||||
throw new Error('Provider 不存在')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== providerLoadRequestId) return
|
||||
showError(parseApiError(err, '加载失败'), '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === providerLoadRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载端点列表
|
||||
async function loadEndpoints() {
|
||||
if (!props.providerId) return
|
||||
const requestId = ++endpointsLoadRequestId
|
||||
|
||||
try {
|
||||
// 并行加载端点列表、Provider 级别的 keys、models 和映射预览
|
||||
@@ -2613,6 +2628,7 @@ async function loadEndpoints() {
|
||||
getProviderModels(props.providerId).catch(() => []),
|
||||
getProviderMappingPreview(props.providerId).catch(() => null),
|
||||
])
|
||||
if (requestId !== endpointsLoadRequestId) return
|
||||
|
||||
providerKeys.value = providerKeysResult
|
||||
providerModels.value = modelsResult
|
||||
@@ -2627,6 +2643,7 @@ async function loadEndpoints() {
|
||||
return aIdx - bIdx
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== endpointsLoadRequestId) return
|
||||
showError(parseApiError(err, '加载端点失败'), '错误')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import ScatterChart from '@/components/charts/ScatterChart.vue'
|
||||
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
|
||||
@@ -73,6 +73,10 @@ const props = withDefaults(defineProps<{
|
||||
const loading = ref(false)
|
||||
const timelineData = ref<IntervalTimelineResponse | null>(null)
|
||||
const primaryColor = ref('201, 100, 66') // 默认主题色
|
||||
let loadRequestId = 0
|
||||
|
||||
const ADMIN_TIMELINE_LIMIT = 1500
|
||||
const USER_TIMELINE_LIMIT = 1200
|
||||
|
||||
// 获取主题色
|
||||
function getPrimaryColor(): string {
|
||||
@@ -86,7 +90,7 @@ function getPrimaryColor(): string {
|
||||
|
||||
onMounted(() => {
|
||||
primaryColor.value = getPrimaryColor()
|
||||
loadData()
|
||||
void loadData()
|
||||
})
|
||||
|
||||
// 预定义的颜色列表(用于区分不同用户/模型)
|
||||
@@ -278,35 +282,44 @@ const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
|
||||
}))
|
||||
|
||||
async function loadData() {
|
||||
const requestId = ++loadRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const limit = props.isAdmin ? ADMIN_TIMELINE_LIMIT : USER_TIMELINE_LIMIT
|
||||
if (props.isAdmin) {
|
||||
// 管理员:获取所有用户数据(按比例采样)
|
||||
timelineData.value = await cacheAnalysisApi.getIntervalTimeline({
|
||||
const data = await cacheAnalysisApi.getIntervalTimeline({
|
||||
hours: props.hours,
|
||||
include_user_info: true,
|
||||
limit: 10000,
|
||||
limit,
|
||||
})
|
||||
if (requestId !== loadRequestId) return
|
||||
timelineData.value = data
|
||||
} else {
|
||||
// 普通用户:获取自己的数据
|
||||
timelineData.value = await meApi.getIntervalTimeline({
|
||||
const data = await meApi.getIntervalTimeline({
|
||||
hours: props.hours,
|
||||
limit: 5000,
|
||||
limit,
|
||||
})
|
||||
if (requestId !== loadRequestId) return
|
||||
timelineData.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== loadRequestId) return
|
||||
log.error('加载请求间隔时间线失败:', error)
|
||||
timelineData.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.hours, () => {
|
||||
loadData()
|
||||
watch([() => props.hours, () => props.isAdmin], () => {
|
||||
void loadData()
|
||||
})
|
||||
|
||||
watch(() => props.isAdmin, () => {
|
||||
loadData()
|
||||
onBeforeUnmount(() => {
|
||||
loadRequestId++
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -657,7 +657,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onBeforeUnmount } from 'vue'
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
@@ -716,10 +716,13 @@ const historicalPricing = ref<{
|
||||
} | null>(null)
|
||||
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const autoRefreshing = ref(false)
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
const curlCopying = ref(false)
|
||||
const curlCopied = ref(false)
|
||||
const replayDialogOpen = ref(false)
|
||||
const AUTO_REFRESH_INTERVAL_MS = 1000
|
||||
let loadDetailRequestId = 0
|
||||
let loadDetailInFlight = false
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, (newTab) => {
|
||||
@@ -1097,13 +1100,20 @@ watch(() => props.isOpen, async (isOpen) => {
|
||||
})
|
||||
|
||||
async function loadDetail(id: string, silent = false) {
|
||||
if (silent && loadDetailInFlight) {
|
||||
return
|
||||
}
|
||||
const requestId = ++loadDetailRequestId
|
||||
loadDetailInFlight = true
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
historicalPricing.value = null
|
||||
}
|
||||
error.value = null
|
||||
try {
|
||||
detail.value = await dashboardApi.getRequestDetail(id)
|
||||
const response = await dashboardApi.getRequestDetail(id)
|
||||
if (requestId !== loadDetailRequestId) return
|
||||
detail.value = response
|
||||
|
||||
// 首次加载时选择默认 tab
|
||||
if (!silent) {
|
||||
@@ -1145,14 +1155,18 @@ async function loadDetail(id: string, silent = false) {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== loadDetailRequestId) return
|
||||
log.error('Failed to load request detail:', err)
|
||||
if (!silent) {
|
||||
error.value = '加载请求详情失败'
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
if (!silent && requestId === loadDetailRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
if (requestId === loadDetailRequestId) {
|
||||
loadDetailInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1175,12 +1189,17 @@ function stopAutoRefresh() {
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshTimer.value || !props.requestId || !props.isOpen) {
|
||||
if (autoRefreshTimer.value) {
|
||||
autoRefreshing.value = true
|
||||
return
|
||||
}
|
||||
if (!isPageVisible.value || !props.requestId || !props.isOpen) {
|
||||
autoRefreshing.value = false
|
||||
return
|
||||
}
|
||||
autoRefreshing.value = true
|
||||
autoRefreshTimer.value = setInterval(async () => {
|
||||
if (!props.requestId || !props.isOpen) {
|
||||
if (!isPageVisible.value || !props.requestId || !props.isOpen) {
|
||||
stopAutoRefresh()
|
||||
return
|
||||
}
|
||||
@@ -1218,8 +1237,26 @@ async function refreshDetail() {
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
isPageVisible.value = !document.hidden
|
||||
if (!isPageVisible.value) {
|
||||
stopAutoRefresh()
|
||||
return
|
||||
}
|
||||
if (props.isOpen && props.requestId && !isRequestCompleted()) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopAutoRefresh()
|
||||
loadDetailRequestId += 1
|
||||
loadDetailInFlight = false
|
||||
})
|
||||
|
||||
function formatDateTime(dateStr: string | null | undefined): string {
|
||||
|
||||
@@ -767,7 +767,7 @@ const hasActiveRecords = computed(() => {
|
||||
// 使用 VueUse 的 useIntervalFn 管理计时器(自动清理)
|
||||
const { pause: stopTimer, resume: startTimer } = useIntervalFn(
|
||||
() => { now.value = Date.now() },
|
||||
100,
|
||||
500,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
|
||||
// 当前的日期范围(用于分页请求)
|
||||
const currentDateRange = ref<DateRangeParams | undefined>(undefined)
|
||||
let loadStatsRequestId = 0
|
||||
let loadRecordsRequestId = 0
|
||||
|
||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||
const availableModels = ref<string[]>([])
|
||||
@@ -69,6 +71,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
|
||||
// 加载统计数据(不加载记录)
|
||||
async function loadStats(dateRange?: DateRangeParams) {
|
||||
const requestId = ++loadStatsRequestId
|
||||
isLoadingStats.value = true
|
||||
currentDateRange.value = dateRange
|
||||
|
||||
@@ -82,6 +85,10 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
usageApi.getUsageByApiFormat(dateRange)
|
||||
])
|
||||
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
// statsData may contain additional fields not declared in UsageStats
|
||||
const statsRaw = statsData as Record<string, unknown>
|
||||
stats.value = {
|
||||
@@ -138,6 +145,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
} else {
|
||||
// 用户页面
|
||||
const userData = await meApi.getUsage(dateRange)
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
stats.value = {
|
||||
total_requests: userData.total_requests || 0,
|
||||
@@ -227,6 +237,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
.sort((a, b) => b.request_count - a.request_count)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
if (getErrorStatus(error) !== 403) {
|
||||
log.error('加载统计数据失败:', error)
|
||||
}
|
||||
@@ -234,7 +247,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
modelStats.value = []
|
||||
currentRecords.value = []
|
||||
} finally {
|
||||
isLoadingStats.value = false
|
||||
if (requestId === loadStatsRequestId) {
|
||||
isLoadingStats.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +258,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
pagination: PaginationParams,
|
||||
filters?: FilterParams
|
||||
): Promise<void> {
|
||||
const requestId = ++loadRecordsRequestId
|
||||
isLoadingRecords.value = true
|
||||
|
||||
try {
|
||||
@@ -279,22 +295,33 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
const nextRecords = (response.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = response.total || 0
|
||||
} else {
|
||||
// 用户页面:使用用户 API
|
||||
const userData = await meApi.getUsage(params)
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
log.error('加载记录失败:', error)
|
||||
currentRecords.value = []
|
||||
totalRecords.value = 0
|
||||
} finally {
|
||||
isLoadingRecords.value = false
|
||||
if (requestId === loadRecordsRequestId) {
|
||||
isLoadingRecords.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -385,7 +385,6 @@ const isDemo = computed(() => isDemoMode())
|
||||
|
||||
const showAuthError = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
let authCheckInterval: number | null = null
|
||||
|
||||
// 更新检查相关
|
||||
const showUpdateDialog = ref(false)
|
||||
@@ -437,12 +436,35 @@ async function checkForUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncAuthNotice() {
|
||||
authStore.syncToken()
|
||||
showAuthError.value = !!authStore.user && !authStore.token
|
||||
}
|
||||
|
||||
function handleStorageChange(event: StorageEvent) {
|
||||
if (event.key === null || event.key === 'access_token') {
|
||||
syncAuthNotice()
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden) {
|
||||
syncAuthNotice()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [authStore.user, authStore.token] as const,
|
||||
() => {
|
||||
showAuthError.value = !!authStore.user && !authStore.token
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
authCheckInterval = setInterval(() => {
|
||||
if (authStore.user && !authStore.token) {
|
||||
showAuthError.value = true
|
||||
}
|
||||
}, 5000)
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
syncAuthNotice()
|
||||
|
||||
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
|
||||
if (authStore.user?.role === 'admin' && !moduleStore.loaded && !moduleStore.loading) {
|
||||
@@ -456,10 +478,8 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (authCheckInterval) {
|
||||
clearInterval(authCheckInterval)
|
||||
authCheckInterval = null
|
||||
}
|
||||
window.removeEventListener('storage', handleStorageChange)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
|
||||
function handleRelogin() {
|
||||
|
||||
@@ -11,6 +11,7 @@ interface CacheItem<T> {
|
||||
|
||||
class MemoryCache {
|
||||
private cache: Map<string, CacheItem<unknown>> = new Map()
|
||||
private inFlight: Map<string, Promise<unknown>> = new Map()
|
||||
private defaultTTL = 60000 // 默认缓存60秒
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,7 @@ class MemoryCache {
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
this.inFlight.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +83,27 @@ class MemoryCache {
|
||||
size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进行中的请求
|
||||
*/
|
||||
getInFlight<T>(key: string): Promise<T> | null {
|
||||
return (this.inFlight.get(key) as Promise<T> | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记进行中的请求
|
||||
*/
|
||||
setInFlight<T>(key: string, promise: Promise<T>): void {
|
||||
this.inFlight.set(key, promise as Promise<unknown>)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除进行中的请求
|
||||
*/
|
||||
deleteInFlight(key: string): void {
|
||||
this.inFlight.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局缓存实例
|
||||
@@ -103,18 +126,62 @@ export async function cachedRequest<T>(
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
// 尝试从缓存获取
|
||||
const cached = cache.get<T>(key)
|
||||
if (cached !== null) {
|
||||
return cached
|
||||
if (ttl !== 0) {
|
||||
const cached = cache.get<T>(key)
|
||||
if (cached !== null) {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中,执行请求
|
||||
const data = await fetcher()
|
||||
// 命中进行中的同 key 请求,直接复用
|
||||
const inFlight = cache.getInFlight<T>(key)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
|
||||
// 存入缓存
|
||||
cache.set(key, data, ttl)
|
||||
// 缓存未命中,执行请求并登记为 in-flight
|
||||
const request = (async () => {
|
||||
try {
|
||||
const data = await fetcher()
|
||||
if (ttl !== 0) {
|
||||
cache.set(key, data, ttl)
|
||||
}
|
||||
return data
|
||||
} finally {
|
||||
cache.deleteInFlight(key)
|
||||
}
|
||||
})()
|
||||
|
||||
return data
|
||||
cache.setInFlight(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
export default cache
|
||||
/**
|
||||
* 仅做请求去重(不缓存结果)
|
||||
* 相同 key 的并发请求会复用同一个 Promise
|
||||
*/
|
||||
export function dedupedRequest<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return cachedRequest(key, fetcher, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建归一化的缓存 key
|
||||
* 将 params 的 key 排序并过滤 undefined 值,确保相同参数生成相同 key
|
||||
*/
|
||||
export function buildCacheKey(prefix: string, params?: Record<string, unknown>): string {
|
||||
if (!params) {
|
||||
return prefix
|
||||
}
|
||||
const normalizedParams = Object.entries(params)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
if (normalizedParams.length === 0) {
|
||||
return prefix
|
||||
}
|
||||
return `${prefix}:${JSON.stringify(normalizedParams)}`
|
||||
}
|
||||
|
||||
export default cache
|
||||
|
||||
@@ -911,6 +911,8 @@ const showDetail = ref(false)
|
||||
const selectedTask = ref<AsyncTaskDetail | null>(null)
|
||||
const detailAutoRefresh = ref(false)
|
||||
let detailRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
let overviewRefreshInFlight = false
|
||||
|
||||
// 使用记录详情抽屉状态
|
||||
const usageDetailOpen = ref(false)
|
||||
@@ -953,6 +955,16 @@ async function fetchStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOverview() {
|
||||
if (overviewRefreshInFlight) return
|
||||
overviewRefreshInFlight = true
|
||||
try {
|
||||
await Promise.all([fetchTasks(), fetchStats()])
|
||||
} finally {
|
||||
overviewRefreshInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开任务详情
|
||||
async function openTaskDetail(task: AsyncTaskItem) {
|
||||
try {
|
||||
@@ -993,22 +1005,27 @@ function toggleDetailAutoRefresh() {
|
||||
|
||||
// 开始详情自动刷新
|
||||
function startDetailAutoRefresh() {
|
||||
if (!isPageVisible.value) return
|
||||
if (detailRefreshInterval) return
|
||||
// 立即刷新一次
|
||||
refreshTaskDetail()
|
||||
detailRefreshInterval = setInterval(() => {
|
||||
if (selectedTask.value && showDetail.value) {
|
||||
if (isPageVisible.value && selectedTask.value && showDetail.value) {
|
||||
refreshTaskDetail()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// 停止详情自动刷新
|
||||
function stopDetailAutoRefresh() {
|
||||
function pauseDetailAutoRefresh() {
|
||||
if (detailRefreshInterval) {
|
||||
clearInterval(detailRefreshInterval)
|
||||
detailRefreshInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// 停止详情自动刷新
|
||||
function stopDetailAutoRefresh() {
|
||||
pauseDetailAutoRefresh()
|
||||
detailAutoRefresh.value = false
|
||||
}
|
||||
|
||||
@@ -1052,8 +1069,7 @@ async function cancelTask(task: AsyncTaskItem | AsyncTaskDetail) {
|
||||
toast({
|
||||
title: '任务已取消',
|
||||
})
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
await refreshOverview()
|
||||
if (showDetail.value) {
|
||||
closeDetail()
|
||||
}
|
||||
@@ -1222,11 +1238,11 @@ let autoRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
const AUTO_REFRESH_INTERVAL = 5000 // 5秒
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (!isPageVisible.value) return
|
||||
if (autoRefreshInterval) return
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
if (hasProcessingTasks.value && !loading.value) {
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
if (isPageVisible.value && hasProcessingTasks.value && !loading.value) {
|
||||
refreshOverview()
|
||||
}
|
||||
}, AUTO_REFRESH_INTERVAL)
|
||||
}
|
||||
@@ -1240,19 +1256,35 @@ function stopAutoRefresh() {
|
||||
|
||||
// 监听是否有进行中的任务,动态启停自动刷新
|
||||
watch(hasProcessingTasks, (has) => {
|
||||
if (has) {
|
||||
if (has && isPageVisible.value) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function handleVisibilityChange() {
|
||||
isPageVisible.value = !document.hidden
|
||||
if (!isPageVisible.value) {
|
||||
stopAutoRefresh()
|
||||
pauseDetailAutoRefresh()
|
||||
return
|
||||
}
|
||||
if (hasProcessingTasks.value) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
if (detailAutoRefresh.value && selectedTask.value && showDetail.value) {
|
||||
startDetailAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
refreshOverview()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopAutoRefresh()
|
||||
stopDetailAutoRefresh()
|
||||
clearTimeout(filterTimeout)
|
||||
|
||||
@@ -406,7 +406,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
@@ -462,6 +462,7 @@ interface AuditLog {
|
||||
const loading = ref(false)
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const selectedLog = ref<AuditLog | null>(null)
|
||||
let logsRequestId = 0
|
||||
|
||||
// 搜索查询
|
||||
const searchQuery = ref('')
|
||||
@@ -480,9 +481,9 @@ const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalRecords = ref(0)
|
||||
|
||||
let loadTimeout: number
|
||||
let loadTimeout: number | null = null
|
||||
const debouncedLoadLogs = () => {
|
||||
clearTimeout(loadTimeout)
|
||||
if (loadTimeout !== null) clearTimeout(loadTimeout)
|
||||
loadTimeout = window.setTimeout(resetAndLoad, 500)
|
||||
}
|
||||
|
||||
@@ -493,6 +494,7 @@ const hasActiveFilters = computed(() => {
|
||||
})
|
||||
|
||||
async function loadLogs() {
|
||||
const requestId = ++logsRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const offset = (currentPage.value - 1) * pageSize.value
|
||||
@@ -506,14 +508,18 @@ async function loadLogs() {
|
||||
}
|
||||
|
||||
const data = await auditApi.getAuditLogs(filterParams)
|
||||
if (requestId !== logsRequestId) return
|
||||
logs.value = data.items || []
|
||||
totalRecords.value = data.meta?.total ?? logs.value.length
|
||||
} catch (error) {
|
||||
if (requestId !== logsRequestId) return
|
||||
log.error('获取审计日志失败:', error)
|
||||
logs.value = []
|
||||
totalRecords.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === logsRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,4 +717,12 @@ function formatDateTime(dateStr: string): string {
|
||||
onMounted(() => {
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (loadTimeout !== null) {
|
||||
clearTimeout(loadTimeout)
|
||||
loadTimeout = null
|
||||
}
|
||||
logsRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -47,6 +47,7 @@ const clearingRowAffinityKey = ref<string | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const currentTime = ref(Math.floor(Date.now() / 1000))
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
|
||||
// ==================== 模型映射缓存 ====================
|
||||
|
||||
@@ -236,6 +237,7 @@ function handlePageChange() {
|
||||
// ==================== 定时器管理 ====================
|
||||
|
||||
function startCountdown() {
|
||||
if (!isPageVisible.value) return
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
@@ -260,6 +262,16 @@ function stopCountdown() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
isPageVisible.value = !document.hidden
|
||||
if (!isPageVisible.value) {
|
||||
stopCountdown()
|
||||
return
|
||||
}
|
||||
currentTime.value = Math.floor(Date.now() / 1000)
|
||||
startCountdown()
|
||||
}
|
||||
|
||||
// ==================== 模型映射缓存方法 ====================
|
||||
|
||||
async function fetchModelMappingStats() {
|
||||
@@ -431,6 +443,7 @@ watch(tableKeyword, (value) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
fetchCacheStats()
|
||||
fetchCacheConfig()
|
||||
fetchAffinityList()
|
||||
@@ -441,6 +454,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||
stopCountdown()
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { TimeRangePicker } from '@/components/common'
|
||||
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
|
||||
@@ -93,6 +93,13 @@ const providerStats = ref<ProviderStatsItem[]>([])
|
||||
|
||||
const forecastLoading = ref(false)
|
||||
const quotaLoading = ref(false)
|
||||
let forecastRequestId = 0
|
||||
let savingsRequestId = 0
|
||||
let quotaRequestId = 0
|
||||
let providerStatsRequestId = 0
|
||||
let loadAllPromise: Promise<void> | null = null
|
||||
let hasPendingLoadAll = false
|
||||
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const forecastHistory = computed(() => forecast.value?.history || [])
|
||||
const forecastFuture = computed(() => forecast.value?.forecast || [])
|
||||
@@ -108,40 +115,93 @@ function buildTimeRangeParams() {
|
||||
}
|
||||
|
||||
async function loadForecast() {
|
||||
const requestId = ++forecastRequestId
|
||||
forecastLoading.value = true
|
||||
try {
|
||||
forecast.value = await adminApi.getCostForecast(buildTimeRangeParams())
|
||||
const data = await adminApi.getCostForecast(buildTimeRangeParams())
|
||||
if (requestId !== forecastRequestId) return
|
||||
forecast.value = data
|
||||
} finally {
|
||||
forecastLoading.value = false
|
||||
if (requestId === forecastRequestId) {
|
||||
forecastLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSavings() {
|
||||
costSavings.value = await adminApi.getCostSavings(buildTimeRangeParams())
|
||||
const requestId = ++savingsRequestId
|
||||
const data = await adminApi.getCostSavings(buildTimeRangeParams())
|
||||
if (requestId !== savingsRequestId) return
|
||||
costSavings.value = data
|
||||
}
|
||||
|
||||
async function loadQuotaUsage() {
|
||||
const requestId = ++quotaRequestId
|
||||
quotaLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getQuotaUsage()
|
||||
if (requestId !== quotaRequestId) return
|
||||
quotaProviders.value = response.providers
|
||||
} finally {
|
||||
quotaLoading.value = false
|
||||
if (requestId === quotaRequestId) {
|
||||
quotaLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderStats() {
|
||||
providerStats.value = await usageApi.getUsageByProvider({
|
||||
const requestId = ++providerStatsRequestId
|
||||
const stats = await usageApi.getUsageByProvider({
|
||||
...buildTimeRangeParams(),
|
||||
limit: 8
|
||||
})
|
||||
if (requestId !== providerStatsRequestId) return
|
||||
providerStats.value = stats
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
|
||||
if (loadAllPromise) {
|
||||
hasPendingLoadAll = true
|
||||
return loadAllPromise
|
||||
}
|
||||
loadAllPromise = Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
loadAllPromise = null
|
||||
if (hasPendingLoadAll) {
|
||||
hasPendingLoadAll = false
|
||||
void loadAll()
|
||||
}
|
||||
})
|
||||
return loadAllPromise
|
||||
}
|
||||
|
||||
watch(timeRange, loadAll, { deep: true })
|
||||
function scheduleLoadAll() {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
}
|
||||
loadAllDebounceTimer = setTimeout(() => {
|
||||
loadAllDebounceTimer = null
|
||||
void loadAll()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
watch(timeRange, scheduleLoadAll, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
loadAllDebounceTimer = null
|
||||
}
|
||||
hasPendingLoadAll = false
|
||||
loadAllPromise = null
|
||||
forecastRequestId += 1
|
||||
savingsRequestId += 1
|
||||
quotaRequestId += 1
|
||||
providerStatsRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -611,7 +611,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
@@ -703,6 +703,11 @@ const editingModel = ref<GlobalModelResponse | null>(null)
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const GLOBAL_MODELS_FETCH_PAGE_SIZE = 1000
|
||||
let globalModelsRequestId = 0
|
||||
let modelSelectionRequestId = 0
|
||||
let modelProvidersRequestId = 0
|
||||
let providersRequestId = 0
|
||||
let providerOptionsRequest: Promise<void> | null = null
|
||||
|
||||
// 模型目录分页
|
||||
const catalogCurrentPage = ref(1)
|
||||
@@ -1025,19 +1030,27 @@ watch([searchQuery, capabilityFilters], () => {
|
||||
}, { deep: true })
|
||||
|
||||
async function loadGlobalModels() {
|
||||
const requestId = ++globalModelsRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const allModels: GlobalModelResponse[] = []
|
||||
let skip = 0
|
||||
let expectedTotal: number | null = null
|
||||
|
||||
while (true) {
|
||||
const response = await listGlobalModels({
|
||||
skip,
|
||||
limit: GLOBAL_MODELS_FETCH_PAGE_SIZE,
|
||||
})
|
||||
if (expectedTotal === null && typeof response.total === 'number') {
|
||||
expectedTotal = response.total
|
||||
}
|
||||
const pageModels = response.models || []
|
||||
allModels.push(...pageModels)
|
||||
|
||||
if (expectedTotal !== null && allModels.length >= expectedTotal) {
|
||||
break
|
||||
}
|
||||
if (pageModels.length < GLOBAL_MODELS_FETCH_PAGE_SIZE) {
|
||||
break
|
||||
}
|
||||
@@ -1045,12 +1058,16 @@ async function loadGlobalModels() {
|
||||
skip += pageModels.length
|
||||
}
|
||||
|
||||
if (requestId !== globalModelsRequestId) return
|
||||
globalModels.value = allModels
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== globalModelsRequestId) return
|
||||
log.error('加载模型失败:', err)
|
||||
showError(parseApiError(err, '加载模型失败'), '加载模型失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === globalModelsRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,6 +1081,7 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
|
||||
}
|
||||
|
||||
async function selectModel(model: GlobalModelResponse) {
|
||||
const requestId = ++modelSelectionRequestId
|
||||
// 先显示缓存数据,提升响应速度
|
||||
selectedModel.value = model
|
||||
detailTab.value = 'basic'
|
||||
@@ -1078,6 +1096,7 @@ async function selectModel(model: GlobalModelResponse) {
|
||||
])
|
||||
|
||||
// 更新为最新数据(如果获取成功)
|
||||
if (requestId !== modelSelectionRequestId) return
|
||||
if (latestModel) {
|
||||
selectedModel.value = latestModel
|
||||
}
|
||||
@@ -1096,10 +1115,12 @@ async function refreshSelectedModel() {
|
||||
|
||||
// 加载指定模型的关联提供商
|
||||
async function loadModelProviders(_globalModelId: string) {
|
||||
const requestId = ++modelProvidersRequestId
|
||||
loadingModelProviders.value = true
|
||||
try {
|
||||
// 使用新的 API 获取所有关联提供商(包括非活跃的)
|
||||
const response = await getGlobalModelProviders(_globalModelId)
|
||||
if (requestId !== modelProvidersRequestId) return
|
||||
|
||||
// 转换为展示格式
|
||||
selectedModelProviders.value = response.providers.map(p => ({
|
||||
@@ -1124,11 +1145,14 @@ async function loadModelProviders(_globalModelId: string) {
|
||||
supports_streaming: p.supports_streaming
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== modelProvidersRequestId) return
|
||||
log.error('加载关联提供商失败:', err)
|
||||
showError(parseApiError(err, '加载关联提供商失败'), '错误')
|
||||
selectedModelProviders.value = []
|
||||
} finally {
|
||||
loadingModelProviders.value = false
|
||||
if (requestId === modelProvidersRequestId) {
|
||||
loadingModelProviders.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,14 +1161,25 @@ async function ensureProviderOptions() {
|
||||
if (providerOptions.value.length > 0 || loadingProviderOptions.value) {
|
||||
return
|
||||
}
|
||||
if (providerOptionsRequest) {
|
||||
await providerOptionsRequest
|
||||
return
|
||||
}
|
||||
providerOptionsRequest = (async () => {
|
||||
try {
|
||||
loadingProviderOptions.value = true
|
||||
providerOptions.value = await getProvidersSummary()
|
||||
} catch (err: unknown) {
|
||||
const message = parseApiError(err, '加载 Provider 列表失败')
|
||||
showError(message, '错误')
|
||||
} finally {
|
||||
loadingProviderOptions.value = false
|
||||
}
|
||||
})()
|
||||
try {
|
||||
loadingProviderOptions.value = true
|
||||
providerOptions.value = await getProvidersSummary()
|
||||
} catch (err: unknown) {
|
||||
const message = parseApiError(err, '加载 Provider 列表失败')
|
||||
showError(message, '错误')
|
||||
await providerOptionsRequest
|
||||
} finally {
|
||||
loadingProviderOptions.value = false
|
||||
providerOptionsRequest = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1320,6 +1355,8 @@ async function confirmBatchDeleteModels() {
|
||||
// 抽屉控制函数
|
||||
function handleDrawerOpenChange(value: boolean) {
|
||||
if (!value && !hasBlockingDialogOpen.value) {
|
||||
modelSelectionRequestId += 1
|
||||
modelProvidersRequestId += 1
|
||||
selectedModel.value = null
|
||||
}
|
||||
}
|
||||
@@ -1455,9 +1492,13 @@ async function refreshData() {
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
const requestId = ++providersRequestId
|
||||
try {
|
||||
providers.value = await getProvidersSummary()
|
||||
const nextProviders = await getProvidersSummary()
|
||||
if (requestId !== providersRequestId) return
|
||||
providers.value = nextProviders
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== providersRequestId) return
|
||||
showError(parseApiError(err, '加载 Provider 列表失败'), '加载 Provider 列表失败')
|
||||
}
|
||||
}
|
||||
@@ -1468,6 +1509,13 @@ onMounted(async () => {
|
||||
loadProviders(),
|
||||
])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
globalModelsRequestId += 1
|
||||
modelSelectionRequestId += 1
|
||||
modelProvidersRequestId += 1
|
||||
providersRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { LoadingState, TimeRangePicker } from '@/components/common'
|
||||
import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
|
||||
@@ -112,6 +112,12 @@ const errorLoading = ref(false)
|
||||
|
||||
const providerStatus = ref<ProviderStatus[]>([])
|
||||
const providerLoading = ref(false)
|
||||
let percentilesRequestId = 0
|
||||
let errorsRequestId = 0
|
||||
let providersRequestId = 0
|
||||
let loadAllPromise: Promise<void> | null = null
|
||||
let hasPendingLoadAll = false
|
||||
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function buildTimeRangeParams() {
|
||||
return {
|
||||
@@ -124,31 +130,45 @@ function buildTimeRangeParams() {
|
||||
}
|
||||
|
||||
async function loadPercentiles() {
|
||||
const requestId = ++percentilesRequestId
|
||||
percentileLoading.value = true
|
||||
try {
|
||||
percentiles.value = await adminApi.getPercentiles(buildTimeRangeParams())
|
||||
const data = await adminApi.getPercentiles(buildTimeRangeParams())
|
||||
if (requestId !== percentilesRequestId) return
|
||||
percentiles.value = data
|
||||
} finally {
|
||||
percentileLoading.value = false
|
||||
if (requestId === percentilesRequestId) {
|
||||
percentileLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadErrors() {
|
||||
const requestId = ++errorsRequestId
|
||||
errorLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getErrorDistribution(buildTimeRangeParams())
|
||||
if (requestId !== errorsRequestId) return
|
||||
errorDistribution.value = response.distribution
|
||||
errorTrend.value = response.trend
|
||||
} finally {
|
||||
errorLoading.value = false
|
||||
if (requestId === errorsRequestId) {
|
||||
errorLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
const requestId = ++providersRequestId
|
||||
providerLoading.value = true
|
||||
try {
|
||||
providerStatus.value = await dashboardApi.getProviderStatus()
|
||||
const data = await dashboardApi.getProviderStatus()
|
||||
if (requestId !== providersRequestId) return
|
||||
providerStatus.value = data
|
||||
} finally {
|
||||
providerLoading.value = false
|
||||
if (requestId === providersRequestId) {
|
||||
providerLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +186,47 @@ const errorTrendChartData = computed(() => ({
|
||||
}))
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
|
||||
if (loadAllPromise) {
|
||||
hasPendingLoadAll = true
|
||||
return loadAllPromise
|
||||
}
|
||||
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
loadAllPromise = null
|
||||
if (hasPendingLoadAll) {
|
||||
hasPendingLoadAll = false
|
||||
void loadAll()
|
||||
}
|
||||
})
|
||||
return loadAllPromise
|
||||
}
|
||||
|
||||
watch(timeRange, loadAll, { deep: true })
|
||||
function scheduleLoadAll() {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
}
|
||||
loadAllDebounceTimer = setTimeout(() => {
|
||||
loadAllDebounceTimer = null
|
||||
void loadAll()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
watch(timeRange, scheduleLoadAll, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
loadAllDebounceTimer = null
|
||||
}
|
||||
hasPendingLoadAll = false
|
||||
loadAllPromise = null
|
||||
percentilesRequestId += 1
|
||||
errorsRequestId += 1
|
||||
providersRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1158,7 +1158,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
Search,
|
||||
Upload,
|
||||
@@ -1243,11 +1243,19 @@ const proxyNodesStore = useProxyNodesStore()
|
||||
// --- Overview ---
|
||||
const poolProviders = ref<PoolOverviewItem[]>([])
|
||||
const overviewLoading = ref(true)
|
||||
let overviewRequestId = 0
|
||||
let selectProviderRequestId = 0
|
||||
let providerDataRequestId = 0
|
||||
let keysRequestId = 0
|
||||
let keysSearchDebounceTimer: number | null = null
|
||||
let suppressFiltersWatch = false
|
||||
|
||||
async function loadOverview() {
|
||||
const requestId = ++overviewRequestId
|
||||
overviewLoading.value = true
|
||||
try {
|
||||
const res = await getPoolOverview()
|
||||
if (requestId !== overviewRequestId) return
|
||||
const enabledProviders = res.items.filter(item => item.pool_enabled)
|
||||
poolProviders.value = enabledProviders
|
||||
|
||||
@@ -1266,9 +1274,12 @@ async function loadOverview() {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== overviewRequestId) return
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
overviewLoading.value = false
|
||||
if (requestId === overviewRequestId) {
|
||||
overviewLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1306,6 +1317,7 @@ const showAccountQuotaColumn = computed(() => {
|
||||
})
|
||||
|
||||
async function selectProvider(id: string) {
|
||||
const requestId = ++selectProviderRequestId
|
||||
selectedProviderId.value = id
|
||||
editingKeyDetail.value = null
|
||||
keyPermissionsDialogOpen.value = false
|
||||
@@ -1315,16 +1327,27 @@ async function selectProvider(id: string) {
|
||||
proxyMobilePopoverOpenKeyId.value = null
|
||||
schedulingDetailDesktopPopoverOpenKeyId.value = null
|
||||
schedulingDetailMobilePopoverOpenKeyId.value = null
|
||||
suppressFiltersWatch = true
|
||||
currentPage.value = 1
|
||||
searchQuery.value = ''
|
||||
statusFilter.value = 'all'
|
||||
suppressFiltersWatch = false
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
keysSearchDebounceTimer = null
|
||||
}
|
||||
await Promise.all([loadKeys(), loadProviderData(id)])
|
||||
if (requestId !== selectProviderRequestId) return
|
||||
}
|
||||
|
||||
async function loadProviderData(id: string) {
|
||||
const requestId = ++providerDataRequestId
|
||||
try {
|
||||
selectedProviderData.value = await getProvider(id)
|
||||
const providerData = await getProvider(id)
|
||||
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
|
||||
selectedProviderData.value = providerData
|
||||
} catch {
|
||||
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
|
||||
selectedProviderData.value = null
|
||||
}
|
||||
}
|
||||
@@ -1434,25 +1457,52 @@ async function refreshCurrentPage() {
|
||||
|
||||
async function loadKeys() {
|
||||
if (!selectedProviderId.value) return
|
||||
const requestId = ++keysRequestId
|
||||
const providerId = selectedProviderId.value
|
||||
const page = currentPage.value
|
||||
const pageSizeValue = pageSize.value
|
||||
const search = searchQuery.value || undefined
|
||||
const status = statusFilter.value as 'all' | 'active' | 'cooldown' | 'inactive'
|
||||
keysLoading.value = true
|
||||
try {
|
||||
keyPage.value = await listPoolKeys(selectedProviderId.value, {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
search: searchQuery.value || undefined,
|
||||
status: statusFilter.value as 'all' | 'active' | 'cooldown' | 'inactive',
|
||||
const nextPage = await listPoolKeys(providerId, {
|
||||
page,
|
||||
page_size: pageSizeValue,
|
||||
search,
|
||||
status,
|
||||
})
|
||||
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
||||
keyPage.value = nextPage
|
||||
} catch (err) {
|
||||
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
keysLoading.value = false
|
||||
if (requestId === keysRequestId) {
|
||||
keysLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([currentPage, pageSize], () => loadKeys())
|
||||
watch([searchQuery, statusFilter], () => {
|
||||
watch([currentPage, pageSize], () => {
|
||||
void loadKeys()
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
if (suppressFiltersWatch) return
|
||||
currentPage.value = 1
|
||||
loadKeys()
|
||||
void loadKeys()
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
if (suppressFiltersWatch) return
|
||||
currentPage.value = 1
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
}
|
||||
keysSearchDebounceTimer = window.setTimeout(() => {
|
||||
keysSearchDebounceTimer = null
|
||||
void loadKeys()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function normalizeAuthTypeForEdit(authType: string): EndpointAPIKey['auth_type'] {
|
||||
@@ -2224,4 +2274,15 @@ onMounted(async () => {
|
||||
await loadOverview()
|
||||
void refreshCurrentPageQuotaInBackground({ silent: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
keysSearchDebounceTimer = null
|
||||
}
|
||||
overviewRequestId += 1
|
||||
selectProviderRequestId += 1
|
||||
providerDataRequestId += 1
|
||||
keysRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -226,6 +226,7 @@ const { confirmDanger } = useConfirm()
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
let providersRequestId = 0
|
||||
const providerDialogOpen = ref(false)
|
||||
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
||||
const priorityDialogOpen = ref(false)
|
||||
@@ -350,15 +351,21 @@ async function loadGlobalModelList() {
|
||||
|
||||
// 加载提供商列表
|
||||
async function loadProviders() {
|
||||
const requestId = ++providersRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
providers.value = await getProvidersSummary()
|
||||
const nextProviders = await getProvidersSummary()
|
||||
if (requestId !== providersRequestId) return
|
||||
providers.value = nextProviders
|
||||
// 异步加载配置了 ops 的 provider 的余额数据
|
||||
loadBalances(providers.value)
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== providersRequestId) return
|
||||
showError(parseApiError(err, '加载提供商列表失败'), '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === providersRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import { LoadingState, TimeRangePicker } from '@/components/common'
|
||||
@@ -184,6 +184,15 @@ const summaryLoading = ref(false)
|
||||
const series = ref<TimeSeriesItem[]>([])
|
||||
const comparisonSeries = ref<TimeSeriesItem[]>([])
|
||||
const seriesLoading = ref(false)
|
||||
let leaderboardRequestId = 0
|
||||
let summaryRequestId = 0
|
||||
let seriesRequestId = 0
|
||||
let leaderboardLoadPromise: Promise<void> | null = null
|
||||
let hasPendingLeaderboardLoad = false
|
||||
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let userPanelsLoadPromise: Promise<void> | null = null
|
||||
let hasPendingUserPanelsLoad = false
|
||||
let userPanelsDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function buildTimeRangeParams() {
|
||||
return {
|
||||
@@ -204,6 +213,12 @@ async function loadUsers() {
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
if (leaderboardLoadPromise) {
|
||||
hasPendingLeaderboardLoad = true
|
||||
return leaderboardLoadPromise
|
||||
}
|
||||
leaderboardLoadPromise = (async () => {
|
||||
const requestId = ++leaderboardRequestId
|
||||
leaderboardLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getLeaderboardUsers({
|
||||
@@ -211,46 +226,90 @@ async function loadLeaderboard() {
|
||||
metric: metric.value,
|
||||
limit: 10
|
||||
})
|
||||
if (requestId !== leaderboardRequestId) return
|
||||
leaderboard.value = response.items
|
||||
} finally {
|
||||
leaderboardLoading.value = false
|
||||
if (requestId === leaderboardRequestId) {
|
||||
leaderboardLoading.value = false
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
leaderboardLoadPromise = null
|
||||
if (hasPendingLeaderboardLoad) {
|
||||
hasPendingLeaderboardLoad = false
|
||||
void loadLeaderboard()
|
||||
}
|
||||
})
|
||||
return leaderboardLoadPromise
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
if (!selectedUserId.value) return
|
||||
const requestId = ++summaryRequestId
|
||||
summaryLoading.value = true
|
||||
try {
|
||||
userSummary.value = await usageApi.getUsageStats({
|
||||
const summary = await usageApi.getUsageStats({
|
||||
...buildTimeRangeParams(),
|
||||
user_id: selectedUserId.value
|
||||
})
|
||||
if (requestId !== summaryRequestId) return
|
||||
userSummary.value = summary
|
||||
} finally {
|
||||
summaryLoading.value = false
|
||||
if (requestId === summaryRequestId) {
|
||||
summaryLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSeries() {
|
||||
if (!selectedUserId.value) return
|
||||
const requestId = ++seriesRequestId
|
||||
seriesLoading.value = true
|
||||
try {
|
||||
series.value = await adminApi.getTimeSeries({
|
||||
const baseParams = {
|
||||
...buildTimeRangeParams(),
|
||||
user_id: selectedUserId.value
|
||||
})
|
||||
|
||||
comparisonSeries.value = []
|
||||
if (compareUserId.value && compareUserId.value !== '__none__') {
|
||||
comparisonSeries.value = await adminApi.getTimeSeries({
|
||||
}
|
||||
const shouldCompare = Boolean(compareUserId.value && compareUserId.value !== '__none__')
|
||||
const comparePromise: Promise<TimeSeriesItem[]> = shouldCompare
|
||||
? adminApi.getTimeSeries({
|
||||
...buildTimeRangeParams(),
|
||||
user_id: compareUserId.value
|
||||
})
|
||||
}
|
||||
: Promise.resolve([])
|
||||
|
||||
const [primarySeries, compareSeries] = await Promise.all([
|
||||
adminApi.getTimeSeries(baseParams),
|
||||
comparePromise
|
||||
])
|
||||
|
||||
if (requestId !== seriesRequestId) return
|
||||
series.value = primarySeries
|
||||
comparisonSeries.value = compareSeries
|
||||
} finally {
|
||||
seriesLoading.value = false
|
||||
if (requestId === seriesRequestId) {
|
||||
seriesLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserPanels() {
|
||||
if (userPanelsLoadPromise) {
|
||||
hasPendingUserPanelsLoad = true
|
||||
return userPanelsLoadPromise
|
||||
}
|
||||
userPanelsLoadPromise = Promise.all([loadSummary(), loadSeries()])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
userPanelsLoadPromise = null
|
||||
if (hasPendingUserPanelsLoad) {
|
||||
hasPendingUserPanelsLoad = false
|
||||
void loadUserPanels()
|
||||
}
|
||||
})
|
||||
return userPanelsLoadPromise
|
||||
}
|
||||
|
||||
const seriesChartData = computed(() => ({
|
||||
labels: series.value.map(item => item.date),
|
||||
datasets: [
|
||||
@@ -284,16 +343,52 @@ const comparisonChartData = computed(() => ({
|
||||
]
|
||||
}))
|
||||
|
||||
watch([timeRange, metric], loadLeaderboard, { deep: true })
|
||||
watch([timeRange, selectedUserId, compareUserId], () => {
|
||||
loadSummary()
|
||||
loadSeries()
|
||||
}, { deep: true })
|
||||
function scheduleLeaderboardLoad() {
|
||||
if (leaderboardDebounceTimer) {
|
||||
clearTimeout(leaderboardDebounceTimer)
|
||||
}
|
||||
leaderboardDebounceTimer = setTimeout(() => {
|
||||
leaderboardDebounceTimer = null
|
||||
void loadLeaderboard()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function scheduleUserPanelsLoad() {
|
||||
if (userPanelsDebounceTimer) {
|
||||
clearTimeout(userPanelsDebounceTimer)
|
||||
}
|
||||
userPanelsDebounceTimer = setTimeout(() => {
|
||||
userPanelsDebounceTimer = null
|
||||
void loadUserPanels()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
watch([timeRange, metric], scheduleLeaderboardLoad, { deep: true })
|
||||
watch([timeRange, selectedUserId, compareUserId], scheduleUserPanelsLoad, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadUsers()
|
||||
await loadLeaderboard()
|
||||
await loadSummary()
|
||||
await loadSeries()
|
||||
await Promise.all([
|
||||
loadLeaderboard(),
|
||||
loadUserPanels()
|
||||
])
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (leaderboardDebounceTimer) {
|
||||
clearTimeout(leaderboardDebounceTimer)
|
||||
leaderboardDebounceTimer = null
|
||||
}
|
||||
if (userPanelsDebounceTimer) {
|
||||
clearTimeout(userPanelsDebounceTimer)
|
||||
userPanelsDebounceTimer = null
|
||||
}
|
||||
hasPendingLeaderboardLoad = false
|
||||
hasPendingUserPanelsLoad = false
|
||||
leaderboardLoadPromise = null
|
||||
userPanelsLoadPromise = null
|
||||
leaderboardRequestId += 1
|
||||
summaryRequestId += 1
|
||||
seriesRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -793,6 +793,7 @@ const apiKeyInput = ref<HTMLInputElement>()
|
||||
// 用户统计
|
||||
const userStats = ref<Record<string, UsageByUser>>({})
|
||||
const loadingStats = ref(false)
|
||||
let userStatsRequestId = 0
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('all')
|
||||
@@ -846,13 +847,17 @@ watch([searchQuery, filterRole, filterStatus], () => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await usersStore.fetchUsers()
|
||||
await loadUserStats()
|
||||
await Promise.all([
|
||||
usersStore.fetchUsers(),
|
||||
loadUserStats()
|
||||
])
|
||||
})
|
||||
|
||||
async function refreshUsers() {
|
||||
await usersStore.fetchUsers()
|
||||
await loadUserStats()
|
||||
await Promise.all([
|
||||
usersStore.fetchUsers(),
|
||||
loadUserStats()
|
||||
])
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
@@ -860,9 +865,11 @@ function formatDate(dateString: string) {
|
||||
}
|
||||
|
||||
async function loadUserStats() {
|
||||
const requestId = ++userStatsRequestId
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const data = await usageApi.getUsageByUser()
|
||||
if (requestId !== userStatsRequestId) return
|
||||
userStats.value = data.reduce((acc: Record<string, UsageByUser>, stat: UsageByUser) => {
|
||||
acc[stat.user_id] = stat
|
||||
return acc
|
||||
@@ -870,7 +877,9 @@ async function loadUserStats() {
|
||||
} catch (err) {
|
||||
log.error('加载用户统计失败:', err)
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
if (requestId === userStatsRequestId) {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
id="compressed-log-retention-days"
|
||||
:model-value="compressedLogRetentionDays"
|
||||
type="number"
|
||||
placeholder="90"
|
||||
placeholder="30"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:compressedLogRetentionDays', Number($event))"
|
||||
/>
|
||||
@@ -98,7 +98,7 @@
|
||||
for="log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
完整记录保留天数
|
||||
请求记录保存天数
|
||||
</Label>
|
||||
<Input
|
||||
id="log-retention-days"
|
||||
|
||||
@@ -111,7 +111,7 @@ function createDefaultConfig(): SystemConfig {
|
||||
// 请求记录清理
|
||||
enable_auto_cleanup: true,
|
||||
detail_log_retention_days: 7,
|
||||
compressed_log_retention_days: 90,
|
||||
compressed_log_retention_days: 30,
|
||||
header_retention_days: 90,
|
||||
log_retention_days: 365,
|
||||
cleanup_batch_size: 1000,
|
||||
@@ -179,7 +179,7 @@ export function useSystemConfig() {
|
||||
systemConfig.value.max_request_body_size !== originalConfig.value.max_request_body_size ||
|
||||
systemConfig.value.max_response_body_size !== originalConfig.value.max_response_body_size ||
|
||||
JSON.stringify(systemConfig.value.sensitive_headers) !==
|
||||
JSON.stringify(originalConfig.value.sensitive_headers)
|
||||
JSON.stringify(originalConfig.value.sensitive_headers)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -187,14 +187,14 @@ export function useSystemConfig() {
|
||||
if (!originalConfig.value) return false
|
||||
return (
|
||||
systemConfig.value.detail_log_retention_days !==
|
||||
originalConfig.value.detail_log_retention_days ||
|
||||
originalConfig.value.detail_log_retention_days ||
|
||||
systemConfig.value.compressed_log_retention_days !==
|
||||
originalConfig.value.compressed_log_retention_days ||
|
||||
originalConfig.value.compressed_log_retention_days ||
|
||||
systemConfig.value.header_retention_days !== originalConfig.value.header_retention_days ||
|
||||
systemConfig.value.log_retention_days !== originalConfig.value.log_retention_days ||
|
||||
systemConfig.value.cleanup_batch_size !== originalConfig.value.cleanup_batch_size ||
|
||||
systemConfig.value.audit_log_retention_days !==
|
||||
originalConfig.value.audit_log_retention_days
|
||||
originalConfig.value.audit_log_retention_days
|
||||
)
|
||||
})
|
||||
|
||||
@@ -231,7 +231,7 @@ export function useSystemConfig() {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig(key)
|
||||
if (response.value !== null && response.value !== undefined) {
|
||||
;(systemConfig.value as Record<string, unknown>)[key] = response.value
|
||||
; (systemConfig.value as Record<string, unknown>)[key] = response.value
|
||||
}
|
||||
} catch {
|
||||
// 配置不存在时使用默认值,无需处理
|
||||
|
||||
@@ -987,6 +987,10 @@ const dailyTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
|
||||
// 统计周期
|
||||
const loadingDaily = ref(false)
|
||||
const loading = ref(false)
|
||||
let dailyStatsRequestId = 0
|
||||
let dailyStatsLoadPromise: Promise<void> | null = null
|
||||
let hasPendingDailyStatsLoad = false
|
||||
let dailyStatsDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
// 公告
|
||||
@@ -1279,7 +1283,8 @@ onMounted(async () => {
|
||||
}
|
||||
await Promise.all([
|
||||
loadDashboardData(),
|
||||
loadAnnouncements()
|
||||
loadAnnouncements(),
|
||||
loadDailyStats()
|
||||
])
|
||||
await nextTick()
|
||||
setupTimelineResizeObserver()
|
||||
@@ -1298,6 +1303,13 @@ onBeforeUnmount(() => {
|
||||
statsPanelObserver = null
|
||||
announcementsTimelineObserver?.disconnect()
|
||||
announcementsTimelineObserver = null
|
||||
if (dailyStatsDebounceTimer) {
|
||||
clearTimeout(dailyStatsDebounceTimer)
|
||||
dailyStatsDebounceTimer = null
|
||||
}
|
||||
hasPendingDailyStatsLoad = false
|
||||
dailyStatsLoadPromise = null
|
||||
dailyStatsRequestId += 1
|
||||
})
|
||||
|
||||
async function loadDashboardData() {
|
||||
@@ -1326,22 +1338,48 @@ async function loadDashboardData() {
|
||||
}
|
||||
|
||||
async function loadDailyStats() {
|
||||
loadingDaily.value = true
|
||||
try {
|
||||
const response = await dashboardApi.getDailyStats(dailyTimeRange.value)
|
||||
dailyStats.value = response.daily_stats
|
||||
providerSummary.value = response.provider_summary || []
|
||||
} catch {
|
||||
dailyStats.value = []
|
||||
providerSummary.value = []
|
||||
} finally {
|
||||
loadingDaily.value = false
|
||||
if (dailyStatsLoadPromise) {
|
||||
hasPendingDailyStatsLoad = true
|
||||
return dailyStatsLoadPromise
|
||||
}
|
||||
const requestId = ++dailyStatsRequestId
|
||||
loadingDaily.value = true
|
||||
dailyStatsLoadPromise = (async () => {
|
||||
try {
|
||||
const response = await dashboardApi.getDailyStats(dailyTimeRange.value)
|
||||
if (requestId !== dailyStatsRequestId) return
|
||||
dailyStats.value = response.daily_stats
|
||||
providerSummary.value = response.provider_summary || []
|
||||
} catch {
|
||||
if (requestId !== dailyStatsRequestId) return
|
||||
dailyStats.value = []
|
||||
providerSummary.value = []
|
||||
} finally {
|
||||
if (requestId === dailyStatsRequestId) {
|
||||
loadingDaily.value = false
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
dailyStatsLoadPromise = null
|
||||
if (hasPendingDailyStatsLoad) {
|
||||
hasPendingDailyStatsLoad = false
|
||||
void loadDailyStats()
|
||||
}
|
||||
})
|
||||
return dailyStatsLoadPromise
|
||||
}
|
||||
|
||||
watch(dailyTimeRange, async () => {
|
||||
await loadDailyStats()
|
||||
}, { deep: true })
|
||||
function scheduleDailyStatsLoad() {
|
||||
if (dailyStatsDebounceTimer) {
|
||||
clearTimeout(dailyStatsDebounceTimer)
|
||||
}
|
||||
dailyStatsDebounceTimer = setTimeout(() => {
|
||||
dailyStatsDebounceTimer = null
|
||||
void loadDailyStats()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
watch(dailyTimeRange, scheduleDailyStatsLoad, { deep: true })
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
|
||||
@@ -247,14 +247,17 @@ const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
||||
// 自动刷新定时器
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let refreshInFlight: Promise<void> | null = null
|
||||
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
||||
const GLOBAL_AUTO_REFRESH_INTERVAL = 5000 // 5秒刷新一次(全局自动刷新)
|
||||
const globalAutoRefresh = ref(false) // 全局自动刷新开关
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
|
||||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||||
|
||||
let pollInFlight = false
|
||||
async function pollActiveRequests() {
|
||||
if (!isPageVisible.value) return
|
||||
if (!hasActiveRequests.value) return
|
||||
if (pollInFlight) return
|
||||
pollInFlight = true
|
||||
@@ -338,6 +341,7 @@ async function pollActiveRequests() {
|
||||
|
||||
// 启动自动刷新
|
||||
function startAutoRefresh() {
|
||||
if (!isPageVisible.value) return
|
||||
if (autoRefreshTimer) return
|
||||
autoRefreshTimer = setInterval(pollActiveRequests, AUTO_REFRESH_INTERVAL)
|
||||
}
|
||||
@@ -353,7 +357,7 @@ function stopAutoRefresh() {
|
||||
// 监听活跃请求状态,自动启动/停止刷新
|
||||
// 1秒轮询始终用于活跃请求的实时更新,不受全局刷新影响
|
||||
watch(hasActiveRequests, (hasActive) => {
|
||||
if (hasActive) {
|
||||
if (hasActive && isPageVisible.value) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
@@ -362,6 +366,7 @@ watch(hasActiveRequests, (hasActive) => {
|
||||
|
||||
// 启动全局自动刷新
|
||||
function startGlobalAutoRefresh() {
|
||||
if (!isPageVisible.value) return
|
||||
if (globalAutoRefreshTimer) return
|
||||
globalAutoRefreshTimer = setInterval(refreshData, GLOBAL_AUTO_REFRESH_INTERVAL)
|
||||
}
|
||||
@@ -378,15 +383,34 @@ function stopGlobalAutoRefresh() {
|
||||
function handleAutoRefreshChange(value: boolean) {
|
||||
globalAutoRefresh.value = value
|
||||
if (value) {
|
||||
refreshData() // 立即刷新一次
|
||||
if (isPageVisible.value) {
|
||||
refreshData() // 立即刷新一次
|
||||
}
|
||||
startGlobalAutoRefresh()
|
||||
} else {
|
||||
stopGlobalAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
isPageVisible.value = !document.hidden
|
||||
if (!isPageVisible.value) {
|
||||
stopAutoRefresh()
|
||||
stopGlobalAutoRefresh()
|
||||
return
|
||||
}
|
||||
if (hasActiveRequests.value) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
if (globalAutoRefresh.value) {
|
||||
refreshData()
|
||||
startGlobalAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopAutoRefresh()
|
||||
stopGlobalAutoRefresh()
|
||||
})
|
||||
@@ -419,6 +443,8 @@ const selectedRequestId = ref<string | null>(null)
|
||||
|
||||
// 初始化加载
|
||||
onMounted(async () => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// 所有数据源并行加载(stats/heatmap/records/users 之间没有数据依赖)
|
||||
const statsTask = loadStats(timeRange.value).catch(err => {
|
||||
log.error('加载统计数据失败:', err)
|
||||
@@ -548,11 +574,28 @@ async function handleFilterStatusChange(value: string) {
|
||||
|
||||
// 刷新数据
|
||||
async function refreshData() {
|
||||
await loadStats(timeRange.value)
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
if (!isPageVisible.value) return
|
||||
if (refreshInFlight) return refreshInFlight
|
||||
|
||||
refreshInFlight = (async () => {
|
||||
if (isAdminPage.value) {
|
||||
// loadStats 会同步更新 currentDateRange,随后 loadRecords 复用同一时间范围
|
||||
await Promise.all([
|
||||
loadStats(timeRange.value),
|
||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
await loadStats(timeRange.value)
|
||||
// 用户页面:loadStats 已包含记录加载
|
||||
})()
|
||||
|
||||
try {
|
||||
await refreshInFlight
|
||||
} finally {
|
||||
refreshInFlight = null
|
||||
}
|
||||
// 用户页面:loadStats 已包含记录加载
|
||||
}
|
||||
|
||||
// 显示请求详情
|
||||
|
||||
Reference in New Issue
Block a user