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:
fawney19
2026-03-03 22:04:40 +08:00
parent 0a60492146
commit 97b0146ce9
66 changed files with 2306 additions and 657 deletions

View File

@@ -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
)
},
}

View File

@@ -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
)
}
}

View File

@@ -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

View File

@@ -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
})
}
/**

View File

@@ -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(

View File

@@ -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
})
}

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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, '加载端点失败'), '错误')
}
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -767,7 +767,7 @@ const hasActiveRecords = computed(() => {
// 使用 VueUse 的 useIntervalFn 管理计时器(自动清理)
const { pause: stopTimer, resume: startTimer } = useIntervalFn(
() => { now.value = Date.now() },
100,
500,
{ immediate: false }
)

View File

@@ -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
}
}
}

View File

@@ -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() {

View File

@@ -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

View File

@@ -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)

View File

@@ -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>

View File

@@ -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()
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}
}
}

View File

@@ -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>

View File

@@ -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
}
}
}

View File

@@ -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"

View File

@@ -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 {
// 配置不存在时使用默认值,无需处理

View File

@@ -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)

View File

@@ -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 已包含记录加载
}
// 显示请求详情