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

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