mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
perf: 全栈查询优化、前端缓存去重与页面可见性优化
后端: - SQL count 查询统一改用 func.count() 子查询替代 query.count() - Dashboard/Audit 等页面多次独立查询合并为单次聚合查询 - Provider summary 列表改为批量查询消除 N+1 问题 - DailyStats 逐天循环查询改为 CASE 分桶单次查询 - 使用 load_only() 减少不必要的列加载 - cache_decorator 支持嵌套属性路径解析(dotted vary_by) - 多个管理/公共端点新增 @cache_result 缓存装饰 前端: - cache.ts 新增 in-flight 请求复用、dedupedRequest、buildCacheKey - 大量 API 调用添加前端缓存或去重 - 多个页面定时器在标签页隐藏时暂停、可见时恢复 - Auth 检查从 setInterval 改为 storage + visibilitychange 事件驱动 - 请求竞态防护(requestId 模式) 数据库: - Usage 表新增 idx_usage_status_user_created 复合索引
This commit is contained in:
@@ -911,6 +911,8 @@ const showDetail = ref(false)
|
||||
const selectedTask = ref<AsyncTaskDetail | null>(null)
|
||||
const detailAutoRefresh = ref(false)
|
||||
let detailRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
let overviewRefreshInFlight = false
|
||||
|
||||
// 使用记录详情抽屉状态
|
||||
const usageDetailOpen = ref(false)
|
||||
@@ -953,6 +955,16 @@ async function fetchStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOverview() {
|
||||
if (overviewRefreshInFlight) return
|
||||
overviewRefreshInFlight = true
|
||||
try {
|
||||
await Promise.all([fetchTasks(), fetchStats()])
|
||||
} finally {
|
||||
overviewRefreshInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开任务详情
|
||||
async function openTaskDetail(task: AsyncTaskItem) {
|
||||
try {
|
||||
@@ -993,22 +1005,27 @@ function toggleDetailAutoRefresh() {
|
||||
|
||||
// 开始详情自动刷新
|
||||
function startDetailAutoRefresh() {
|
||||
if (!isPageVisible.value) return
|
||||
if (detailRefreshInterval) return
|
||||
// 立即刷新一次
|
||||
refreshTaskDetail()
|
||||
detailRefreshInterval = setInterval(() => {
|
||||
if (selectedTask.value && showDetail.value) {
|
||||
if (isPageVisible.value && selectedTask.value && showDetail.value) {
|
||||
refreshTaskDetail()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// 停止详情自动刷新
|
||||
function stopDetailAutoRefresh() {
|
||||
function pauseDetailAutoRefresh() {
|
||||
if (detailRefreshInterval) {
|
||||
clearInterval(detailRefreshInterval)
|
||||
detailRefreshInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// 停止详情自动刷新
|
||||
function stopDetailAutoRefresh() {
|
||||
pauseDetailAutoRefresh()
|
||||
detailAutoRefresh.value = false
|
||||
}
|
||||
|
||||
@@ -1052,8 +1069,7 @@ async function cancelTask(task: AsyncTaskItem | AsyncTaskDetail) {
|
||||
toast({
|
||||
title: '任务已取消',
|
||||
})
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
await refreshOverview()
|
||||
if (showDetail.value) {
|
||||
closeDetail()
|
||||
}
|
||||
@@ -1222,11 +1238,11 @@ let autoRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
const AUTO_REFRESH_INTERVAL = 5000 // 5秒
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (!isPageVisible.value) return
|
||||
if (autoRefreshInterval) return
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
if (hasProcessingTasks.value && !loading.value) {
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
if (isPageVisible.value && hasProcessingTasks.value && !loading.value) {
|
||||
refreshOverview()
|
||||
}
|
||||
}, AUTO_REFRESH_INTERVAL)
|
||||
}
|
||||
@@ -1240,19 +1256,35 @@ function stopAutoRefresh() {
|
||||
|
||||
// 监听是否有进行中的任务,动态启停自动刷新
|
||||
watch(hasProcessingTasks, (has) => {
|
||||
if (has) {
|
||||
if (has && isPageVisible.value) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function handleVisibilityChange() {
|
||||
isPageVisible.value = !document.hidden
|
||||
if (!isPageVisible.value) {
|
||||
stopAutoRefresh()
|
||||
pauseDetailAutoRefresh()
|
||||
return
|
||||
}
|
||||
if (hasProcessingTasks.value) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
if (detailAutoRefresh.value && selectedTask.value && showDetail.value) {
|
||||
startDetailAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
refreshOverview()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopAutoRefresh()
|
||||
stopDetailAutoRefresh()
|
||||
clearTimeout(filterTimeout)
|
||||
|
||||
@@ -406,7 +406,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
@@ -462,6 +462,7 @@ interface AuditLog {
|
||||
const loading = ref(false)
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const selectedLog = ref<AuditLog | null>(null)
|
||||
let logsRequestId = 0
|
||||
|
||||
// 搜索查询
|
||||
const searchQuery = ref('')
|
||||
@@ -480,9 +481,9 @@ const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalRecords = ref(0)
|
||||
|
||||
let loadTimeout: number
|
||||
let loadTimeout: number | null = null
|
||||
const debouncedLoadLogs = () => {
|
||||
clearTimeout(loadTimeout)
|
||||
if (loadTimeout !== null) clearTimeout(loadTimeout)
|
||||
loadTimeout = window.setTimeout(resetAndLoad, 500)
|
||||
}
|
||||
|
||||
@@ -493,6 +494,7 @@ const hasActiveFilters = computed(() => {
|
||||
})
|
||||
|
||||
async function loadLogs() {
|
||||
const requestId = ++logsRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const offset = (currentPage.value - 1) * pageSize.value
|
||||
@@ -506,14 +508,18 @@ async function loadLogs() {
|
||||
}
|
||||
|
||||
const data = await auditApi.getAuditLogs(filterParams)
|
||||
if (requestId !== logsRequestId) return
|
||||
logs.value = data.items || []
|
||||
totalRecords.value = data.meta?.total ?? logs.value.length
|
||||
} catch (error) {
|
||||
if (requestId !== logsRequestId) return
|
||||
log.error('获取审计日志失败:', error)
|
||||
logs.value = []
|
||||
totalRecords.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === logsRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,4 +717,12 @@ function formatDateTime(dateStr: string): string {
|
||||
onMounted(() => {
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (loadTimeout !== null) {
|
||||
clearTimeout(loadTimeout)
|
||||
loadTimeout = null
|
||||
}
|
||||
logsRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -47,6 +47,7 @@ const clearingRowAffinityKey = ref<string | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const currentTime = ref(Math.floor(Date.now() / 1000))
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
|
||||
// ==================== 模型映射缓存 ====================
|
||||
|
||||
@@ -236,6 +237,7 @@ function handlePageChange() {
|
||||
// ==================== 定时器管理 ====================
|
||||
|
||||
function startCountdown() {
|
||||
if (!isPageVisible.value) return
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
@@ -260,6 +262,16 @@ function stopCountdown() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
isPageVisible.value = !document.hidden
|
||||
if (!isPageVisible.value) {
|
||||
stopCountdown()
|
||||
return
|
||||
}
|
||||
currentTime.value = Math.floor(Date.now() / 1000)
|
||||
startCountdown()
|
||||
}
|
||||
|
||||
// ==================== 模型映射缓存方法 ====================
|
||||
|
||||
async function fetchModelMappingStats() {
|
||||
@@ -431,6 +443,7 @@ watch(tableKeyword, (value) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
fetchCacheStats()
|
||||
fetchCacheConfig()
|
||||
fetchAffinityList()
|
||||
@@ -441,6 +454,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||
stopCountdown()
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { TimeRangePicker } from '@/components/common'
|
||||
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
|
||||
@@ -93,6 +93,13 @@ const providerStats = ref<ProviderStatsItem[]>([])
|
||||
|
||||
const forecastLoading = ref(false)
|
||||
const quotaLoading = ref(false)
|
||||
let forecastRequestId = 0
|
||||
let savingsRequestId = 0
|
||||
let quotaRequestId = 0
|
||||
let providerStatsRequestId = 0
|
||||
let loadAllPromise: Promise<void> | null = null
|
||||
let hasPendingLoadAll = false
|
||||
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const forecastHistory = computed(() => forecast.value?.history || [])
|
||||
const forecastFuture = computed(() => forecast.value?.forecast || [])
|
||||
@@ -108,40 +115,93 @@ function buildTimeRangeParams() {
|
||||
}
|
||||
|
||||
async function loadForecast() {
|
||||
const requestId = ++forecastRequestId
|
||||
forecastLoading.value = true
|
||||
try {
|
||||
forecast.value = await adminApi.getCostForecast(buildTimeRangeParams())
|
||||
const data = await adminApi.getCostForecast(buildTimeRangeParams())
|
||||
if (requestId !== forecastRequestId) return
|
||||
forecast.value = data
|
||||
} finally {
|
||||
forecastLoading.value = false
|
||||
if (requestId === forecastRequestId) {
|
||||
forecastLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSavings() {
|
||||
costSavings.value = await adminApi.getCostSavings(buildTimeRangeParams())
|
||||
const requestId = ++savingsRequestId
|
||||
const data = await adminApi.getCostSavings(buildTimeRangeParams())
|
||||
if (requestId !== savingsRequestId) return
|
||||
costSavings.value = data
|
||||
}
|
||||
|
||||
async function loadQuotaUsage() {
|
||||
const requestId = ++quotaRequestId
|
||||
quotaLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getQuotaUsage()
|
||||
if (requestId !== quotaRequestId) return
|
||||
quotaProviders.value = response.providers
|
||||
} finally {
|
||||
quotaLoading.value = false
|
||||
if (requestId === quotaRequestId) {
|
||||
quotaLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderStats() {
|
||||
providerStats.value = await usageApi.getUsageByProvider({
|
||||
const requestId = ++providerStatsRequestId
|
||||
const stats = await usageApi.getUsageByProvider({
|
||||
...buildTimeRangeParams(),
|
||||
limit: 8
|
||||
})
|
||||
if (requestId !== providerStatsRequestId) return
|
||||
providerStats.value = stats
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
|
||||
if (loadAllPromise) {
|
||||
hasPendingLoadAll = true
|
||||
return loadAllPromise
|
||||
}
|
||||
loadAllPromise = Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
loadAllPromise = null
|
||||
if (hasPendingLoadAll) {
|
||||
hasPendingLoadAll = false
|
||||
void loadAll()
|
||||
}
|
||||
})
|
||||
return loadAllPromise
|
||||
}
|
||||
|
||||
watch(timeRange, loadAll, { deep: true })
|
||||
function scheduleLoadAll() {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
}
|
||||
loadAllDebounceTimer = setTimeout(() => {
|
||||
loadAllDebounceTimer = null
|
||||
void loadAll()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
watch(timeRange, scheduleLoadAll, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
loadAllDebounceTimer = null
|
||||
}
|
||||
hasPendingLoadAll = false
|
||||
loadAllPromise = null
|
||||
forecastRequestId += 1
|
||||
savingsRequestId += 1
|
||||
quotaRequestId += 1
|
||||
providerStatsRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -611,7 +611,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
@@ -703,6 +703,11 @@ const editingModel = ref<GlobalModelResponse | null>(null)
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const GLOBAL_MODELS_FETCH_PAGE_SIZE = 1000
|
||||
let globalModelsRequestId = 0
|
||||
let modelSelectionRequestId = 0
|
||||
let modelProvidersRequestId = 0
|
||||
let providersRequestId = 0
|
||||
let providerOptionsRequest: Promise<void> | null = null
|
||||
|
||||
// 模型目录分页
|
||||
const catalogCurrentPage = ref(1)
|
||||
@@ -1025,19 +1030,27 @@ watch([searchQuery, capabilityFilters], () => {
|
||||
}, { deep: true })
|
||||
|
||||
async function loadGlobalModels() {
|
||||
const requestId = ++globalModelsRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const allModels: GlobalModelResponse[] = []
|
||||
let skip = 0
|
||||
let expectedTotal: number | null = null
|
||||
|
||||
while (true) {
|
||||
const response = await listGlobalModels({
|
||||
skip,
|
||||
limit: GLOBAL_MODELS_FETCH_PAGE_SIZE,
|
||||
})
|
||||
if (expectedTotal === null && typeof response.total === 'number') {
|
||||
expectedTotal = response.total
|
||||
}
|
||||
const pageModels = response.models || []
|
||||
allModels.push(...pageModels)
|
||||
|
||||
if (expectedTotal !== null && allModels.length >= expectedTotal) {
|
||||
break
|
||||
}
|
||||
if (pageModels.length < GLOBAL_MODELS_FETCH_PAGE_SIZE) {
|
||||
break
|
||||
}
|
||||
@@ -1045,12 +1058,16 @@ async function loadGlobalModels() {
|
||||
skip += pageModels.length
|
||||
}
|
||||
|
||||
if (requestId !== globalModelsRequestId) return
|
||||
globalModels.value = allModels
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== globalModelsRequestId) return
|
||||
log.error('加载模型失败:', err)
|
||||
showError(parseApiError(err, '加载模型失败'), '加载模型失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === globalModelsRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,6 +1081,7 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
|
||||
}
|
||||
|
||||
async function selectModel(model: GlobalModelResponse) {
|
||||
const requestId = ++modelSelectionRequestId
|
||||
// 先显示缓存数据,提升响应速度
|
||||
selectedModel.value = model
|
||||
detailTab.value = 'basic'
|
||||
@@ -1078,6 +1096,7 @@ async function selectModel(model: GlobalModelResponse) {
|
||||
])
|
||||
|
||||
// 更新为最新数据(如果获取成功)
|
||||
if (requestId !== modelSelectionRequestId) return
|
||||
if (latestModel) {
|
||||
selectedModel.value = latestModel
|
||||
}
|
||||
@@ -1096,10 +1115,12 @@ async function refreshSelectedModel() {
|
||||
|
||||
// 加载指定模型的关联提供商
|
||||
async function loadModelProviders(_globalModelId: string) {
|
||||
const requestId = ++modelProvidersRequestId
|
||||
loadingModelProviders.value = true
|
||||
try {
|
||||
// 使用新的 API 获取所有关联提供商(包括非活跃的)
|
||||
const response = await getGlobalModelProviders(_globalModelId)
|
||||
if (requestId !== modelProvidersRequestId) return
|
||||
|
||||
// 转换为展示格式
|
||||
selectedModelProviders.value = response.providers.map(p => ({
|
||||
@@ -1124,11 +1145,14 @@ async function loadModelProviders(_globalModelId: string) {
|
||||
supports_streaming: p.supports_streaming
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== modelProvidersRequestId) return
|
||||
log.error('加载关联提供商失败:', err)
|
||||
showError(parseApiError(err, '加载关联提供商失败'), '错误')
|
||||
selectedModelProviders.value = []
|
||||
} finally {
|
||||
loadingModelProviders.value = false
|
||||
if (requestId === modelProvidersRequestId) {
|
||||
loadingModelProviders.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,14 +1161,25 @@ async function ensureProviderOptions() {
|
||||
if (providerOptions.value.length > 0 || loadingProviderOptions.value) {
|
||||
return
|
||||
}
|
||||
if (providerOptionsRequest) {
|
||||
await providerOptionsRequest
|
||||
return
|
||||
}
|
||||
providerOptionsRequest = (async () => {
|
||||
try {
|
||||
loadingProviderOptions.value = true
|
||||
providerOptions.value = await getProvidersSummary()
|
||||
} catch (err: unknown) {
|
||||
const message = parseApiError(err, '加载 Provider 列表失败')
|
||||
showError(message, '错误')
|
||||
} finally {
|
||||
loadingProviderOptions.value = false
|
||||
}
|
||||
})()
|
||||
try {
|
||||
loadingProviderOptions.value = true
|
||||
providerOptions.value = await getProvidersSummary()
|
||||
} catch (err: unknown) {
|
||||
const message = parseApiError(err, '加载 Provider 列表失败')
|
||||
showError(message, '错误')
|
||||
await providerOptionsRequest
|
||||
} finally {
|
||||
loadingProviderOptions.value = false
|
||||
providerOptionsRequest = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1320,6 +1355,8 @@ async function confirmBatchDeleteModels() {
|
||||
// 抽屉控制函数
|
||||
function handleDrawerOpenChange(value: boolean) {
|
||||
if (!value && !hasBlockingDialogOpen.value) {
|
||||
modelSelectionRequestId += 1
|
||||
modelProvidersRequestId += 1
|
||||
selectedModel.value = null
|
||||
}
|
||||
}
|
||||
@@ -1455,9 +1492,13 @@ async function refreshData() {
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
const requestId = ++providersRequestId
|
||||
try {
|
||||
providers.value = await getProvidersSummary()
|
||||
const nextProviders = await getProvidersSummary()
|
||||
if (requestId !== providersRequestId) return
|
||||
providers.value = nextProviders
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== providersRequestId) return
|
||||
showError(parseApiError(err, '加载 Provider 列表失败'), '加载 Provider 列表失败')
|
||||
}
|
||||
}
|
||||
@@ -1468,6 +1509,13 @@ onMounted(async () => {
|
||||
loadProviders(),
|
||||
])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
globalModelsRequestId += 1
|
||||
modelSelectionRequestId += 1
|
||||
modelProvidersRequestId += 1
|
||||
providersRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { LoadingState, TimeRangePicker } from '@/components/common'
|
||||
import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
|
||||
@@ -112,6 +112,12 @@ const errorLoading = ref(false)
|
||||
|
||||
const providerStatus = ref<ProviderStatus[]>([])
|
||||
const providerLoading = ref(false)
|
||||
let percentilesRequestId = 0
|
||||
let errorsRequestId = 0
|
||||
let providersRequestId = 0
|
||||
let loadAllPromise: Promise<void> | null = null
|
||||
let hasPendingLoadAll = false
|
||||
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function buildTimeRangeParams() {
|
||||
return {
|
||||
@@ -124,31 +130,45 @@ function buildTimeRangeParams() {
|
||||
}
|
||||
|
||||
async function loadPercentiles() {
|
||||
const requestId = ++percentilesRequestId
|
||||
percentileLoading.value = true
|
||||
try {
|
||||
percentiles.value = await adminApi.getPercentiles(buildTimeRangeParams())
|
||||
const data = await adminApi.getPercentiles(buildTimeRangeParams())
|
||||
if (requestId !== percentilesRequestId) return
|
||||
percentiles.value = data
|
||||
} finally {
|
||||
percentileLoading.value = false
|
||||
if (requestId === percentilesRequestId) {
|
||||
percentileLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadErrors() {
|
||||
const requestId = ++errorsRequestId
|
||||
errorLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getErrorDistribution(buildTimeRangeParams())
|
||||
if (requestId !== errorsRequestId) return
|
||||
errorDistribution.value = response.distribution
|
||||
errorTrend.value = response.trend
|
||||
} finally {
|
||||
errorLoading.value = false
|
||||
if (requestId === errorsRequestId) {
|
||||
errorLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
const requestId = ++providersRequestId
|
||||
providerLoading.value = true
|
||||
try {
|
||||
providerStatus.value = await dashboardApi.getProviderStatus()
|
||||
const data = await dashboardApi.getProviderStatus()
|
||||
if (requestId !== providersRequestId) return
|
||||
providerStatus.value = data
|
||||
} finally {
|
||||
providerLoading.value = false
|
||||
if (requestId === providersRequestId) {
|
||||
providerLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +186,47 @@ const errorTrendChartData = computed(() => ({
|
||||
}))
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
|
||||
if (loadAllPromise) {
|
||||
hasPendingLoadAll = true
|
||||
return loadAllPromise
|
||||
}
|
||||
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
loadAllPromise = null
|
||||
if (hasPendingLoadAll) {
|
||||
hasPendingLoadAll = false
|
||||
void loadAll()
|
||||
}
|
||||
})
|
||||
return loadAllPromise
|
||||
}
|
||||
|
||||
watch(timeRange, loadAll, { deep: true })
|
||||
function scheduleLoadAll() {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
}
|
||||
loadAllDebounceTimer = setTimeout(() => {
|
||||
loadAllDebounceTimer = null
|
||||
void loadAll()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
watch(timeRange, scheduleLoadAll, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
loadAllDebounceTimer = null
|
||||
}
|
||||
hasPendingLoadAll = false
|
||||
loadAllPromise = null
|
||||
percentilesRequestId += 1
|
||||
errorsRequestId += 1
|
||||
providersRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1158,7 +1158,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
Search,
|
||||
Upload,
|
||||
@@ -1243,11 +1243,19 @@ const proxyNodesStore = useProxyNodesStore()
|
||||
// --- Overview ---
|
||||
const poolProviders = ref<PoolOverviewItem[]>([])
|
||||
const overviewLoading = ref(true)
|
||||
let overviewRequestId = 0
|
||||
let selectProviderRequestId = 0
|
||||
let providerDataRequestId = 0
|
||||
let keysRequestId = 0
|
||||
let keysSearchDebounceTimer: number | null = null
|
||||
let suppressFiltersWatch = false
|
||||
|
||||
async function loadOverview() {
|
||||
const requestId = ++overviewRequestId
|
||||
overviewLoading.value = true
|
||||
try {
|
||||
const res = await getPoolOverview()
|
||||
if (requestId !== overviewRequestId) return
|
||||
const enabledProviders = res.items.filter(item => item.pool_enabled)
|
||||
poolProviders.value = enabledProviders
|
||||
|
||||
@@ -1266,9 +1274,12 @@ async function loadOverview() {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== overviewRequestId) return
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
overviewLoading.value = false
|
||||
if (requestId === overviewRequestId) {
|
||||
overviewLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1306,6 +1317,7 @@ const showAccountQuotaColumn = computed(() => {
|
||||
})
|
||||
|
||||
async function selectProvider(id: string) {
|
||||
const requestId = ++selectProviderRequestId
|
||||
selectedProviderId.value = id
|
||||
editingKeyDetail.value = null
|
||||
keyPermissionsDialogOpen.value = false
|
||||
@@ -1315,16 +1327,27 @@ async function selectProvider(id: string) {
|
||||
proxyMobilePopoverOpenKeyId.value = null
|
||||
schedulingDetailDesktopPopoverOpenKeyId.value = null
|
||||
schedulingDetailMobilePopoverOpenKeyId.value = null
|
||||
suppressFiltersWatch = true
|
||||
currentPage.value = 1
|
||||
searchQuery.value = ''
|
||||
statusFilter.value = 'all'
|
||||
suppressFiltersWatch = false
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
keysSearchDebounceTimer = null
|
||||
}
|
||||
await Promise.all([loadKeys(), loadProviderData(id)])
|
||||
if (requestId !== selectProviderRequestId) return
|
||||
}
|
||||
|
||||
async function loadProviderData(id: string) {
|
||||
const requestId = ++providerDataRequestId
|
||||
try {
|
||||
selectedProviderData.value = await getProvider(id)
|
||||
const providerData = await getProvider(id)
|
||||
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
|
||||
selectedProviderData.value = providerData
|
||||
} catch {
|
||||
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
|
||||
selectedProviderData.value = null
|
||||
}
|
||||
}
|
||||
@@ -1434,25 +1457,52 @@ async function refreshCurrentPage() {
|
||||
|
||||
async function loadKeys() {
|
||||
if (!selectedProviderId.value) return
|
||||
const requestId = ++keysRequestId
|
||||
const providerId = selectedProviderId.value
|
||||
const page = currentPage.value
|
||||
const pageSizeValue = pageSize.value
|
||||
const search = searchQuery.value || undefined
|
||||
const status = statusFilter.value as 'all' | 'active' | 'cooldown' | 'inactive'
|
||||
keysLoading.value = true
|
||||
try {
|
||||
keyPage.value = await listPoolKeys(selectedProviderId.value, {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
search: searchQuery.value || undefined,
|
||||
status: statusFilter.value as 'all' | 'active' | 'cooldown' | 'inactive',
|
||||
const nextPage = await listPoolKeys(providerId, {
|
||||
page,
|
||||
page_size: pageSizeValue,
|
||||
search,
|
||||
status,
|
||||
})
|
||||
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
||||
keyPage.value = nextPage
|
||||
} catch (err) {
|
||||
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
keysLoading.value = false
|
||||
if (requestId === keysRequestId) {
|
||||
keysLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([currentPage, pageSize], () => loadKeys())
|
||||
watch([searchQuery, statusFilter], () => {
|
||||
watch([currentPage, pageSize], () => {
|
||||
void loadKeys()
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
if (suppressFiltersWatch) return
|
||||
currentPage.value = 1
|
||||
loadKeys()
|
||||
void loadKeys()
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
if (suppressFiltersWatch) return
|
||||
currentPage.value = 1
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
}
|
||||
keysSearchDebounceTimer = window.setTimeout(() => {
|
||||
keysSearchDebounceTimer = null
|
||||
void loadKeys()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function normalizeAuthTypeForEdit(authType: string): EndpointAPIKey['auth_type'] {
|
||||
@@ -2224,4 +2274,15 @@ onMounted(async () => {
|
||||
await loadOverview()
|
||||
void refreshCurrentPageQuotaInBackground({ silent: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
keysSearchDebounceTimer = null
|
||||
}
|
||||
overviewRequestId += 1
|
||||
selectProviderRequestId += 1
|
||||
providerDataRequestId += 1
|
||||
keysRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -226,6 +226,7 @@ const { confirmDanger } = useConfirm()
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
let providersRequestId = 0
|
||||
const providerDialogOpen = ref(false)
|
||||
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
||||
const priorityDialogOpen = ref(false)
|
||||
@@ -350,15 +351,21 @@ async function loadGlobalModelList() {
|
||||
|
||||
// 加载提供商列表
|
||||
async function loadProviders() {
|
||||
const requestId = ++providersRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
providers.value = await getProvidersSummary()
|
||||
const nextProviders = await getProvidersSummary()
|
||||
if (requestId !== providersRequestId) return
|
||||
providers.value = nextProviders
|
||||
// 异步加载配置了 ops 的 provider 的余额数据
|
||||
loadBalances(providers.value)
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== providersRequestId) return
|
||||
showError(parseApiError(err, '加载提供商列表失败'), '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === providersRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import { LoadingState, TimeRangePicker } from '@/components/common'
|
||||
@@ -184,6 +184,15 @@ const summaryLoading = ref(false)
|
||||
const series = ref<TimeSeriesItem[]>([])
|
||||
const comparisonSeries = ref<TimeSeriesItem[]>([])
|
||||
const seriesLoading = ref(false)
|
||||
let leaderboardRequestId = 0
|
||||
let summaryRequestId = 0
|
||||
let seriesRequestId = 0
|
||||
let leaderboardLoadPromise: Promise<void> | null = null
|
||||
let hasPendingLeaderboardLoad = false
|
||||
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let userPanelsLoadPromise: Promise<void> | null = null
|
||||
let hasPendingUserPanelsLoad = false
|
||||
let userPanelsDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function buildTimeRangeParams() {
|
||||
return {
|
||||
@@ -204,6 +213,12 @@ async function loadUsers() {
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
if (leaderboardLoadPromise) {
|
||||
hasPendingLeaderboardLoad = true
|
||||
return leaderboardLoadPromise
|
||||
}
|
||||
leaderboardLoadPromise = (async () => {
|
||||
const requestId = ++leaderboardRequestId
|
||||
leaderboardLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getLeaderboardUsers({
|
||||
@@ -211,46 +226,90 @@ async function loadLeaderboard() {
|
||||
metric: metric.value,
|
||||
limit: 10
|
||||
})
|
||||
if (requestId !== leaderboardRequestId) return
|
||||
leaderboard.value = response.items
|
||||
} finally {
|
||||
leaderboardLoading.value = false
|
||||
if (requestId === leaderboardRequestId) {
|
||||
leaderboardLoading.value = false
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
leaderboardLoadPromise = null
|
||||
if (hasPendingLeaderboardLoad) {
|
||||
hasPendingLeaderboardLoad = false
|
||||
void loadLeaderboard()
|
||||
}
|
||||
})
|
||||
return leaderboardLoadPromise
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
if (!selectedUserId.value) return
|
||||
const requestId = ++summaryRequestId
|
||||
summaryLoading.value = true
|
||||
try {
|
||||
userSummary.value = await usageApi.getUsageStats({
|
||||
const summary = await usageApi.getUsageStats({
|
||||
...buildTimeRangeParams(),
|
||||
user_id: selectedUserId.value
|
||||
})
|
||||
if (requestId !== summaryRequestId) return
|
||||
userSummary.value = summary
|
||||
} finally {
|
||||
summaryLoading.value = false
|
||||
if (requestId === summaryRequestId) {
|
||||
summaryLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSeries() {
|
||||
if (!selectedUserId.value) return
|
||||
const requestId = ++seriesRequestId
|
||||
seriesLoading.value = true
|
||||
try {
|
||||
series.value = await adminApi.getTimeSeries({
|
||||
const baseParams = {
|
||||
...buildTimeRangeParams(),
|
||||
user_id: selectedUserId.value
|
||||
})
|
||||
|
||||
comparisonSeries.value = []
|
||||
if (compareUserId.value && compareUserId.value !== '__none__') {
|
||||
comparisonSeries.value = await adminApi.getTimeSeries({
|
||||
}
|
||||
const shouldCompare = Boolean(compareUserId.value && compareUserId.value !== '__none__')
|
||||
const comparePromise: Promise<TimeSeriesItem[]> = shouldCompare
|
||||
? adminApi.getTimeSeries({
|
||||
...buildTimeRangeParams(),
|
||||
user_id: compareUserId.value
|
||||
})
|
||||
}
|
||||
: Promise.resolve([])
|
||||
|
||||
const [primarySeries, compareSeries] = await Promise.all([
|
||||
adminApi.getTimeSeries(baseParams),
|
||||
comparePromise
|
||||
])
|
||||
|
||||
if (requestId !== seriesRequestId) return
|
||||
series.value = primarySeries
|
||||
comparisonSeries.value = compareSeries
|
||||
} finally {
|
||||
seriesLoading.value = false
|
||||
if (requestId === seriesRequestId) {
|
||||
seriesLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserPanels() {
|
||||
if (userPanelsLoadPromise) {
|
||||
hasPendingUserPanelsLoad = true
|
||||
return userPanelsLoadPromise
|
||||
}
|
||||
userPanelsLoadPromise = Promise.all([loadSummary(), loadSeries()])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
userPanelsLoadPromise = null
|
||||
if (hasPendingUserPanelsLoad) {
|
||||
hasPendingUserPanelsLoad = false
|
||||
void loadUserPanels()
|
||||
}
|
||||
})
|
||||
return userPanelsLoadPromise
|
||||
}
|
||||
|
||||
const seriesChartData = computed(() => ({
|
||||
labels: series.value.map(item => item.date),
|
||||
datasets: [
|
||||
@@ -284,16 +343,52 @@ const comparisonChartData = computed(() => ({
|
||||
]
|
||||
}))
|
||||
|
||||
watch([timeRange, metric], loadLeaderboard, { deep: true })
|
||||
watch([timeRange, selectedUserId, compareUserId], () => {
|
||||
loadSummary()
|
||||
loadSeries()
|
||||
}, { deep: true })
|
||||
function scheduleLeaderboardLoad() {
|
||||
if (leaderboardDebounceTimer) {
|
||||
clearTimeout(leaderboardDebounceTimer)
|
||||
}
|
||||
leaderboardDebounceTimer = setTimeout(() => {
|
||||
leaderboardDebounceTimer = null
|
||||
void loadLeaderboard()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function scheduleUserPanelsLoad() {
|
||||
if (userPanelsDebounceTimer) {
|
||||
clearTimeout(userPanelsDebounceTimer)
|
||||
}
|
||||
userPanelsDebounceTimer = setTimeout(() => {
|
||||
userPanelsDebounceTimer = null
|
||||
void loadUserPanels()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
watch([timeRange, metric], scheduleLeaderboardLoad, { deep: true })
|
||||
watch([timeRange, selectedUserId, compareUserId], scheduleUserPanelsLoad, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await loadUsers()
|
||||
await loadLeaderboard()
|
||||
await loadSummary()
|
||||
await loadSeries()
|
||||
await Promise.all([
|
||||
loadLeaderboard(),
|
||||
loadUserPanels()
|
||||
])
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (leaderboardDebounceTimer) {
|
||||
clearTimeout(leaderboardDebounceTimer)
|
||||
leaderboardDebounceTimer = null
|
||||
}
|
||||
if (userPanelsDebounceTimer) {
|
||||
clearTimeout(userPanelsDebounceTimer)
|
||||
userPanelsDebounceTimer = null
|
||||
}
|
||||
hasPendingLeaderboardLoad = false
|
||||
hasPendingUserPanelsLoad = false
|
||||
leaderboardLoadPromise = null
|
||||
userPanelsLoadPromise = null
|
||||
leaderboardRequestId += 1
|
||||
summaryRequestId += 1
|
||||
seriesRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -793,6 +793,7 @@ const apiKeyInput = ref<HTMLInputElement>()
|
||||
// 用户统计
|
||||
const userStats = ref<Record<string, UsageByUser>>({})
|
||||
const loadingStats = ref(false)
|
||||
let userStatsRequestId = 0
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('all')
|
||||
@@ -846,13 +847,17 @@ watch([searchQuery, filterRole, filterStatus], () => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await usersStore.fetchUsers()
|
||||
await loadUserStats()
|
||||
await Promise.all([
|
||||
usersStore.fetchUsers(),
|
||||
loadUserStats()
|
||||
])
|
||||
})
|
||||
|
||||
async function refreshUsers() {
|
||||
await usersStore.fetchUsers()
|
||||
await loadUserStats()
|
||||
await Promise.all([
|
||||
usersStore.fetchUsers(),
|
||||
loadUserStats()
|
||||
])
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
@@ -860,9 +865,11 @@ function formatDate(dateString: string) {
|
||||
}
|
||||
|
||||
async function loadUserStats() {
|
||||
const requestId = ++userStatsRequestId
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const data = await usageApi.getUsageByUser()
|
||||
if (requestId !== userStatsRequestId) return
|
||||
userStats.value = data.reduce((acc: Record<string, UsageByUser>, stat: UsageByUser) => {
|
||||
acc[stat.user_id] = stat
|
||||
return acc
|
||||
@@ -870,7 +877,9 @@ async function loadUserStats() {
|
||||
} catch (err) {
|
||||
log.error('加载用户统计失败:', err)
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
if (requestId === userStatsRequestId) {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
id="compressed-log-retention-days"
|
||||
:model-value="compressedLogRetentionDays"
|
||||
type="number"
|
||||
placeholder="90"
|
||||
placeholder="30"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:compressedLogRetentionDays', Number($event))"
|
||||
/>
|
||||
@@ -98,7 +98,7 @@
|
||||
for="log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
完整记录保留天数
|
||||
请求记录保存天数
|
||||
</Label>
|
||||
<Input
|
||||
id="log-retention-days"
|
||||
|
||||
@@ -111,7 +111,7 @@ function createDefaultConfig(): SystemConfig {
|
||||
// 请求记录清理
|
||||
enable_auto_cleanup: true,
|
||||
detail_log_retention_days: 7,
|
||||
compressed_log_retention_days: 90,
|
||||
compressed_log_retention_days: 30,
|
||||
header_retention_days: 90,
|
||||
log_retention_days: 365,
|
||||
cleanup_batch_size: 1000,
|
||||
@@ -179,7 +179,7 @@ export function useSystemConfig() {
|
||||
systemConfig.value.max_request_body_size !== originalConfig.value.max_request_body_size ||
|
||||
systemConfig.value.max_response_body_size !== originalConfig.value.max_response_body_size ||
|
||||
JSON.stringify(systemConfig.value.sensitive_headers) !==
|
||||
JSON.stringify(originalConfig.value.sensitive_headers)
|
||||
JSON.stringify(originalConfig.value.sensitive_headers)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -187,14 +187,14 @@ export function useSystemConfig() {
|
||||
if (!originalConfig.value) return false
|
||||
return (
|
||||
systemConfig.value.detail_log_retention_days !==
|
||||
originalConfig.value.detail_log_retention_days ||
|
||||
originalConfig.value.detail_log_retention_days ||
|
||||
systemConfig.value.compressed_log_retention_days !==
|
||||
originalConfig.value.compressed_log_retention_days ||
|
||||
originalConfig.value.compressed_log_retention_days ||
|
||||
systemConfig.value.header_retention_days !== originalConfig.value.header_retention_days ||
|
||||
systemConfig.value.log_retention_days !== originalConfig.value.log_retention_days ||
|
||||
systemConfig.value.cleanup_batch_size !== originalConfig.value.cleanup_batch_size ||
|
||||
systemConfig.value.audit_log_retention_days !==
|
||||
originalConfig.value.audit_log_retention_days
|
||||
originalConfig.value.audit_log_retention_days
|
||||
)
|
||||
})
|
||||
|
||||
@@ -231,7 +231,7 @@ export function useSystemConfig() {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig(key)
|
||||
if (response.value !== null && response.value !== undefined) {
|
||||
;(systemConfig.value as Record<string, unknown>)[key] = response.value
|
||||
; (systemConfig.value as Record<string, unknown>)[key] = response.value
|
||||
}
|
||||
} catch {
|
||||
// 配置不存在时使用默认值,无需处理
|
||||
|
||||
@@ -987,6 +987,10 @@ const dailyTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
|
||||
// 统计周期
|
||||
const loadingDaily = ref(false)
|
||||
const loading = ref(false)
|
||||
let dailyStatsRequestId = 0
|
||||
let dailyStatsLoadPromise: Promise<void> | null = null
|
||||
let hasPendingDailyStatsLoad = false
|
||||
let dailyStatsDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
// 公告
|
||||
@@ -1279,7 +1283,8 @@ onMounted(async () => {
|
||||
}
|
||||
await Promise.all([
|
||||
loadDashboardData(),
|
||||
loadAnnouncements()
|
||||
loadAnnouncements(),
|
||||
loadDailyStats()
|
||||
])
|
||||
await nextTick()
|
||||
setupTimelineResizeObserver()
|
||||
@@ -1298,6 +1303,13 @@ onBeforeUnmount(() => {
|
||||
statsPanelObserver = null
|
||||
announcementsTimelineObserver?.disconnect()
|
||||
announcementsTimelineObserver = null
|
||||
if (dailyStatsDebounceTimer) {
|
||||
clearTimeout(dailyStatsDebounceTimer)
|
||||
dailyStatsDebounceTimer = null
|
||||
}
|
||||
hasPendingDailyStatsLoad = false
|
||||
dailyStatsLoadPromise = null
|
||||
dailyStatsRequestId += 1
|
||||
})
|
||||
|
||||
async function loadDashboardData() {
|
||||
@@ -1326,22 +1338,48 @@ async function loadDashboardData() {
|
||||
}
|
||||
|
||||
async function loadDailyStats() {
|
||||
loadingDaily.value = true
|
||||
try {
|
||||
const response = await dashboardApi.getDailyStats(dailyTimeRange.value)
|
||||
dailyStats.value = response.daily_stats
|
||||
providerSummary.value = response.provider_summary || []
|
||||
} catch {
|
||||
dailyStats.value = []
|
||||
providerSummary.value = []
|
||||
} finally {
|
||||
loadingDaily.value = false
|
||||
if (dailyStatsLoadPromise) {
|
||||
hasPendingDailyStatsLoad = true
|
||||
return dailyStatsLoadPromise
|
||||
}
|
||||
const requestId = ++dailyStatsRequestId
|
||||
loadingDaily.value = true
|
||||
dailyStatsLoadPromise = (async () => {
|
||||
try {
|
||||
const response = await dashboardApi.getDailyStats(dailyTimeRange.value)
|
||||
if (requestId !== dailyStatsRequestId) return
|
||||
dailyStats.value = response.daily_stats
|
||||
providerSummary.value = response.provider_summary || []
|
||||
} catch {
|
||||
if (requestId !== dailyStatsRequestId) return
|
||||
dailyStats.value = []
|
||||
providerSummary.value = []
|
||||
} finally {
|
||||
if (requestId === dailyStatsRequestId) {
|
||||
loadingDaily.value = false
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
dailyStatsLoadPromise = null
|
||||
if (hasPendingDailyStatsLoad) {
|
||||
hasPendingDailyStatsLoad = false
|
||||
void loadDailyStats()
|
||||
}
|
||||
})
|
||||
return dailyStatsLoadPromise
|
||||
}
|
||||
|
||||
watch(dailyTimeRange, async () => {
|
||||
await loadDailyStats()
|
||||
}, { deep: true })
|
||||
function scheduleDailyStatsLoad() {
|
||||
if (dailyStatsDebounceTimer) {
|
||||
clearTimeout(dailyStatsDebounceTimer)
|
||||
}
|
||||
dailyStatsDebounceTimer = setTimeout(() => {
|
||||
dailyStatsDebounceTimer = null
|
||||
void loadDailyStats()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
watch(dailyTimeRange, scheduleDailyStatsLoad, { deep: true })
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
|
||||
@@ -247,14 +247,17 @@ const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
||||
// 自动刷新定时器
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let refreshInFlight: Promise<void> | null = null
|
||||
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
||||
const GLOBAL_AUTO_REFRESH_INTERVAL = 5000 // 5秒刷新一次(全局自动刷新)
|
||||
const globalAutoRefresh = ref(false) // 全局自动刷新开关
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
|
||||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||||
|
||||
let pollInFlight = false
|
||||
async function pollActiveRequests() {
|
||||
if (!isPageVisible.value) return
|
||||
if (!hasActiveRequests.value) return
|
||||
if (pollInFlight) return
|
||||
pollInFlight = true
|
||||
@@ -338,6 +341,7 @@ async function pollActiveRequests() {
|
||||
|
||||
// 启动自动刷新
|
||||
function startAutoRefresh() {
|
||||
if (!isPageVisible.value) return
|
||||
if (autoRefreshTimer) return
|
||||
autoRefreshTimer = setInterval(pollActiveRequests, AUTO_REFRESH_INTERVAL)
|
||||
}
|
||||
@@ -353,7 +357,7 @@ function stopAutoRefresh() {
|
||||
// 监听活跃请求状态,自动启动/停止刷新
|
||||
// 1秒轮询始终用于活跃请求的实时更新,不受全局刷新影响
|
||||
watch(hasActiveRequests, (hasActive) => {
|
||||
if (hasActive) {
|
||||
if (hasActive && isPageVisible.value) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
@@ -362,6 +366,7 @@ watch(hasActiveRequests, (hasActive) => {
|
||||
|
||||
// 启动全局自动刷新
|
||||
function startGlobalAutoRefresh() {
|
||||
if (!isPageVisible.value) return
|
||||
if (globalAutoRefreshTimer) return
|
||||
globalAutoRefreshTimer = setInterval(refreshData, GLOBAL_AUTO_REFRESH_INTERVAL)
|
||||
}
|
||||
@@ -378,15 +383,34 @@ function stopGlobalAutoRefresh() {
|
||||
function handleAutoRefreshChange(value: boolean) {
|
||||
globalAutoRefresh.value = value
|
||||
if (value) {
|
||||
refreshData() // 立即刷新一次
|
||||
if (isPageVisible.value) {
|
||||
refreshData() // 立即刷新一次
|
||||
}
|
||||
startGlobalAutoRefresh()
|
||||
} else {
|
||||
stopGlobalAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
isPageVisible.value = !document.hidden
|
||||
if (!isPageVisible.value) {
|
||||
stopAutoRefresh()
|
||||
stopGlobalAutoRefresh()
|
||||
return
|
||||
}
|
||||
if (hasActiveRequests.value) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
if (globalAutoRefresh.value) {
|
||||
refreshData()
|
||||
startGlobalAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopAutoRefresh()
|
||||
stopGlobalAutoRefresh()
|
||||
})
|
||||
@@ -419,6 +443,8 @@ const selectedRequestId = ref<string | null>(null)
|
||||
|
||||
// 初始化加载
|
||||
onMounted(async () => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// 所有数据源并行加载(stats/heatmap/records/users 之间没有数据依赖)
|
||||
const statsTask = loadStats(timeRange.value).catch(err => {
|
||||
log.error('加载统计数据失败:', err)
|
||||
@@ -548,11 +574,28 @@ async function handleFilterStatusChange(value: string) {
|
||||
|
||||
// 刷新数据
|
||||
async function refreshData() {
|
||||
await loadStats(timeRange.value)
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
if (!isPageVisible.value) return
|
||||
if (refreshInFlight) return refreshInFlight
|
||||
|
||||
refreshInFlight = (async () => {
|
||||
if (isAdminPage.value) {
|
||||
// loadStats 会同步更新 currentDateRange,随后 loadRecords 复用同一时间范围
|
||||
await Promise.all([
|
||||
loadStats(timeRange.value),
|
||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
await loadStats(timeRange.value)
|
||||
// 用户页面:loadStats 已包含记录加载
|
||||
})()
|
||||
|
||||
try {
|
||||
await refreshInFlight
|
||||
} finally {
|
||||
refreshInFlight = null
|
||||
}
|
||||
// 用户页面:loadStats 已包含记录加载
|
||||
}
|
||||
|
||||
// 显示请求详情
|
||||
|
||||
Reference in New Issue
Block a user