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:
@@ -54,7 +54,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import ScatterChart from '@/components/charts/ScatterChart.vue'
|
||||
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
|
||||
@@ -73,6 +73,10 @@ const props = withDefaults(defineProps<{
|
||||
const loading = ref(false)
|
||||
const timelineData = ref<IntervalTimelineResponse | null>(null)
|
||||
const primaryColor = ref('201, 100, 66') // 默认主题色
|
||||
let loadRequestId = 0
|
||||
|
||||
const ADMIN_TIMELINE_LIMIT = 1500
|
||||
const USER_TIMELINE_LIMIT = 1200
|
||||
|
||||
// 获取主题色
|
||||
function getPrimaryColor(): string {
|
||||
@@ -86,7 +90,7 @@ function getPrimaryColor(): string {
|
||||
|
||||
onMounted(() => {
|
||||
primaryColor.value = getPrimaryColor()
|
||||
loadData()
|
||||
void loadData()
|
||||
})
|
||||
|
||||
// 预定义的颜色列表(用于区分不同用户/模型)
|
||||
@@ -278,35 +282,44 @@ const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
|
||||
}))
|
||||
|
||||
async function loadData() {
|
||||
const requestId = ++loadRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const limit = props.isAdmin ? ADMIN_TIMELINE_LIMIT : USER_TIMELINE_LIMIT
|
||||
if (props.isAdmin) {
|
||||
// 管理员:获取所有用户数据(按比例采样)
|
||||
timelineData.value = await cacheAnalysisApi.getIntervalTimeline({
|
||||
const data = await cacheAnalysisApi.getIntervalTimeline({
|
||||
hours: props.hours,
|
||||
include_user_info: true,
|
||||
limit: 10000,
|
||||
limit,
|
||||
})
|
||||
if (requestId !== loadRequestId) return
|
||||
timelineData.value = data
|
||||
} else {
|
||||
// 普通用户:获取自己的数据
|
||||
timelineData.value = await meApi.getIntervalTimeline({
|
||||
const data = await meApi.getIntervalTimeline({
|
||||
hours: props.hours,
|
||||
limit: 5000,
|
||||
limit,
|
||||
})
|
||||
if (requestId !== loadRequestId) return
|
||||
timelineData.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== loadRequestId) return
|
||||
log.error('加载请求间隔时间线失败:', error)
|
||||
timelineData.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.hours, () => {
|
||||
loadData()
|
||||
watch([() => props.hours, () => props.isAdmin], () => {
|
||||
void loadData()
|
||||
})
|
||||
|
||||
watch(() => props.isAdmin, () => {
|
||||
loadData()
|
||||
onBeforeUnmount(() => {
|
||||
loadRequestId++
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -657,7 +657,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onBeforeUnmount } from 'vue'
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
@@ -716,10 +716,13 @@ const historicalPricing = ref<{
|
||||
} | null>(null)
|
||||
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const autoRefreshing = ref(false)
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
const curlCopying = ref(false)
|
||||
const curlCopied = ref(false)
|
||||
const replayDialogOpen = ref(false)
|
||||
const AUTO_REFRESH_INTERVAL_MS = 1000
|
||||
let loadDetailRequestId = 0
|
||||
let loadDetailInFlight = false
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, (newTab) => {
|
||||
@@ -1097,13 +1100,20 @@ watch(() => props.isOpen, async (isOpen) => {
|
||||
})
|
||||
|
||||
async function loadDetail(id: string, silent = false) {
|
||||
if (silent && loadDetailInFlight) {
|
||||
return
|
||||
}
|
||||
const requestId = ++loadDetailRequestId
|
||||
loadDetailInFlight = true
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
historicalPricing.value = null
|
||||
}
|
||||
error.value = null
|
||||
try {
|
||||
detail.value = await dashboardApi.getRequestDetail(id)
|
||||
const response = await dashboardApi.getRequestDetail(id)
|
||||
if (requestId !== loadDetailRequestId) return
|
||||
detail.value = response
|
||||
|
||||
// 首次加载时选择默认 tab
|
||||
if (!silent) {
|
||||
@@ -1145,14 +1155,18 @@ async function loadDetail(id: string, silent = false) {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== loadDetailRequestId) return
|
||||
log.error('Failed to load request detail:', err)
|
||||
if (!silent) {
|
||||
error.value = '加载请求详情失败'
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
if (!silent && requestId === loadDetailRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
if (requestId === loadDetailRequestId) {
|
||||
loadDetailInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1175,12 +1189,17 @@ function stopAutoRefresh() {
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshTimer.value || !props.requestId || !props.isOpen) {
|
||||
if (autoRefreshTimer.value) {
|
||||
autoRefreshing.value = true
|
||||
return
|
||||
}
|
||||
if (!isPageVisible.value || !props.requestId || !props.isOpen) {
|
||||
autoRefreshing.value = false
|
||||
return
|
||||
}
|
||||
autoRefreshing.value = true
|
||||
autoRefreshTimer.value = setInterval(async () => {
|
||||
if (!props.requestId || !props.isOpen) {
|
||||
if (!isPageVisible.value || !props.requestId || !props.isOpen) {
|
||||
stopAutoRefresh()
|
||||
return
|
||||
}
|
||||
@@ -1218,8 +1237,26 @@ async function refreshDetail() {
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
isPageVisible.value = !document.hidden
|
||||
if (!isPageVisible.value) {
|
||||
stopAutoRefresh()
|
||||
return
|
||||
}
|
||||
if (props.isOpen && props.requestId && !isRequestCompleted()) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopAutoRefresh()
|
||||
loadDetailRequestId += 1
|
||||
loadDetailInFlight = false
|
||||
})
|
||||
|
||||
function formatDateTime(dateStr: string | null | undefined): string {
|
||||
|
||||
@@ -767,7 +767,7 @@ const hasActiveRecords = computed(() => {
|
||||
// 使用 VueUse 的 useIntervalFn 管理计时器(自动清理)
|
||||
const { pause: stopTimer, resume: startTimer } = useIntervalFn(
|
||||
() => { now.value = Date.now() },
|
||||
100,
|
||||
500,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
|
||||
// 当前的日期范围(用于分页请求)
|
||||
const currentDateRange = ref<DateRangeParams | undefined>(undefined)
|
||||
let loadStatsRequestId = 0
|
||||
let loadRecordsRequestId = 0
|
||||
|
||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||
const availableModels = ref<string[]>([])
|
||||
@@ -69,6 +71,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
|
||||
// 加载统计数据(不加载记录)
|
||||
async function loadStats(dateRange?: DateRangeParams) {
|
||||
const requestId = ++loadStatsRequestId
|
||||
isLoadingStats.value = true
|
||||
currentDateRange.value = dateRange
|
||||
|
||||
@@ -82,6 +85,10 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
usageApi.getUsageByApiFormat(dateRange)
|
||||
])
|
||||
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
// statsData may contain additional fields not declared in UsageStats
|
||||
const statsRaw = statsData as Record<string, unknown>
|
||||
stats.value = {
|
||||
@@ -138,6 +145,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
} else {
|
||||
// 用户页面
|
||||
const userData = await meApi.getUsage(dateRange)
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
stats.value = {
|
||||
total_requests: userData.total_requests || 0,
|
||||
@@ -227,6 +237,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
.sort((a, b) => b.request_count - a.request_count)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return
|
||||
}
|
||||
if (getErrorStatus(error) !== 403) {
|
||||
log.error('加载统计数据失败:', error)
|
||||
}
|
||||
@@ -234,7 +247,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
modelStats.value = []
|
||||
currentRecords.value = []
|
||||
} finally {
|
||||
isLoadingStats.value = false
|
||||
if (requestId === loadStatsRequestId) {
|
||||
isLoadingStats.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +258,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
pagination: PaginationParams,
|
||||
filters?: FilterParams
|
||||
): Promise<void> {
|
||||
const requestId = ++loadRecordsRequestId
|
||||
isLoadingRecords.value = true
|
||||
|
||||
try {
|
||||
@@ -279,22 +295,33 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
const nextRecords = (response.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = response.total || 0
|
||||
} else {
|
||||
// 用户页面:使用用户 API
|
||||
const userData = await meApi.getUsage(params)
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
log.error('加载记录失败:', error)
|
||||
currentRecords.value = []
|
||||
totalRecords.value = 0
|
||||
} finally {
|
||||
isLoadingRecords.value = false
|
||||
if (requestId === loadRecordsRequestId) {
|
||||
isLoadingRecords.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user