perf(frontend): 收敛导航预取并去重首屏请求

- 导航预取仅保留 pointerdown 触发,移除 mouseenter/focus,避免鼠标划过误触发
- 后台预取只做组件懒加载,不再预取各页业务数据,减少首屏资源争抢
- 版本状态检查增加 sessionStorage 缓存(正常 20 分钟 / 错误 5 分钟 TTL)
- fetchModules、必读公告拉取增加请求去重,避免并发重复请求
- 更新检查改用可清理的定时器,组件卸载时清理
- UsageRecordsTable 搜索防抖改为自定义实现,卸载时取消挂起 emit 并补充测试
This commit is contained in:
elky
2026-07-01 20:42:52 +08:00
parent a0f7074e59
commit 2e5ff32e1a
6 changed files with 158 additions and 110 deletions
@@ -29,8 +29,6 @@
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
]"
@mouseenter="emit('prefetch', item.href)"
@focus="emit('prefetch', item.href)"
@pointerdown="emit('prefetch', item.href)"
@click="handleNavigate(item.href)"
>
@@ -1057,8 +1057,8 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useDebounceFn, useLocalStorage } from '@vueuse/core'
import { ref, computed, onBeforeUnmount, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import {
TableCard,
Badge,
@@ -1355,10 +1355,26 @@ const timeRangeModel = computed({
})
// 通用搜索(输入防抖)
const SEARCH_EMIT_DEBOUNCE_MS = 300
const localSearch = ref(props.filterSearch)
const emitSearchDebounced = useDebounceFn((value: string) => {
emit('update:filterSearch', value)
}, 300)
let searchEmitTimer: ReturnType<typeof setTimeout> | null = null
function cancelPendingSearchEmit() {
if (searchEmitTimer !== null) {
clearTimeout(searchEmitTimer)
searchEmitTimer = null
}
}
function scheduleSearchEmit(value: string) {
cancelPendingSearchEmit()
searchEmitTimer = setTimeout(() => {
searchEmitTimer = null
if (value !== props.filterSearch) {
emit('update:filterSearch', value)
}
}, SEARCH_EMIT_DEBOUNCE_MS)
}
function getDisplayStatus(record: UsageRecord) {
return resolveDisplayRequestStatus(record)
@@ -1422,12 +1438,18 @@ function formatRecordProviderSegment(record: UsageRecord): string {
watch(() => props.filterSearch, (value) => {
if (value !== localSearch.value) {
cancelPendingSearchEmit()
localSearch.value = value
}
})
watch(localSearch, (value) => {
emitSearchDebounced(value)
if (value === props.filterSearch) return
scheduleSearchEmit(value)
})
onBeforeUnmount(() => {
cancelPendingSearchEmit()
})
// 使用复用的行点击逻辑
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, type App } from 'vue'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import UsageRecordsTable from '../UsageRecordsTable.vue'
import type { UsageRecord } from '../../types'
@@ -167,6 +167,7 @@ afterEach(() => {
app.unmount()
root.remove()
}
vi.useRealTimers()
})
describe('UsageRecordsTable', () => {
@@ -314,6 +315,31 @@ describe('UsageRecordsTable', () => {
expect(onUpdateHideUnknownRecords).toHaveBeenCalledWith(true)
})
it('debounces usage search updates', async () => {
vi.useFakeTimers()
const onUpdateFilterSearch = vi.fn()
const root = mountUsageRecordsTable([buildRecord()], {
'onUpdate:filterSearch': onUpdateFilterSearch,
})
const input = root.querySelector<HTMLInputElement>('#usage-records-search')
expect(input).not.toBeNull()
input!.value = 'a'
input!.dispatchEvent(new Event('input'))
input!.value = 'ab'
input!.dispatchEvent(new Event('input'))
input!.value = 'abc'
input!.dispatchEvent(new Event('input'))
await nextTick()
await vi.advanceTimersByTimeAsync(299)
expect(onUpdateFilterSearch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(onUpdateFilterSearch).toHaveBeenCalledTimes(1)
expect(onUpdateFilterSearch).toHaveBeenCalledWith('abc')
})
it('shows retry and fallback markers together when both flags are set', () => {
const root = mountUsageRecordsTable([buildRecord({
has_fallback: true,
+77 -19
View File
@@ -5,12 +5,6 @@
:sidebar-class="sidebarClasses"
:content-class="contentClasses"
>
<!-- GLOBAL TEXTURE (Paper Noise) -->
<div
class="absolute inset-0 pointer-events-none z-0 opacity-[0.03] mix-blend-multiply fixed"
:style="{ backgroundImage: `url(\&quot;data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E\&quot;)` }"
/>
<template #notice>
<div class="flex w-full max-w-3xl items-center justify-between rounded-3xl bg-orange-500 px-6 py-3 text-white shadow-2xl ring-1 ring-white/30">
<div class="flex items-center gap-3">
@@ -197,8 +191,6 @@
:class="isNavActive(item.href)
? 'bg-[#cc785c]/10 dark:bg-[#cc785c]/20 text-[#cc785c] dark:text-[#d4a27f]'
: 'text-[#666663] dark:text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 hover:text-[#191919] dark:hover:text-white'"
@mouseenter="prefetchNavigationItem(item.href)"
@focus="prefetchNavigationItem(item.href)"
@pointerdown="prefetchNavigationItem(item.href)"
@click="mobileMenuOpen = false"
>
@@ -479,8 +471,13 @@ const preparedUpdateVersion = ref<string | null>(
const SOURCE_BUILD_UPDATE_HINT: MessageKey = 'update.error.sourceBuildUpdateHint'
const SOURCE_BUILD_RELEASE_HINT: MessageKey = 'update.error.sourceBuildReleaseHint'
const MANUAL_UPDATE_HINT: MessageKey = 'update.error.manualHint'
const VERSION_STATUS_CACHE_KEY = 'aether_version_status_cache'
const VERSION_STATUS_CACHE_TTL_MS = 20 * 60 * 1000
const VERSION_STATUS_ERROR_CACHE_TTL_MS = 5 * 60 * 1000
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
let updateStatusPollTimer: number | null = null
let updateCheckTimer: number | null = null
let requiredAnnouncementsPromise: Promise<void> | null = null
const updateProgressPercent = computed(() => updateTaskStatus.value?.progress_percent ?? null)
const updateProgressText = computed(() => formatUpdateProgressText(updateTaskStatus.value))
const updateDialogTitle = computed(() => {
@@ -540,6 +537,44 @@ function removeSessionStorageItem(key: string) {
}
}
function readCachedVersionStatus(): CheckUpdateResponse | null {
const raw = readSessionStorageItem(VERSION_STATUS_CACHE_KEY)
if (!raw) return null
try {
const parsed = JSON.parse(raw) as { cachedAt?: unknown; status?: unknown }
const cachedAt = typeof parsed.cachedAt === 'number' ? parsed.cachedAt : 0
const status = parsed.status as CheckUpdateResponse | undefined
if (!status || typeof status !== 'object') return null
const ttl = status.error ? VERSION_STATUS_ERROR_CACHE_TTL_MS : VERSION_STATUS_CACHE_TTL_MS
if (Date.now() - cachedAt > ttl) {
removeSessionStorageItem(VERSION_STATUS_CACHE_KEY)
return null
}
return status
} catch {
removeSessionStorageItem(VERSION_STATUS_CACHE_KEY)
return null
}
}
function cacheVersionStatus(status: CheckUpdateResponse | null) {
if (!status) return
setSessionStorageItem(
VERSION_STATUS_CACHE_KEY,
JSON.stringify({ cachedAt: Date.now(), status })
)
}
function applyCachedVersionStatus(): boolean {
const cached = readCachedVersionStatus()
if (!cached) return false
versionStatus.value = cached
syncSystemUpdatePhase(cached)
return true
}
function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): string {
if (!status) return t('update.progress.downloadPackage')
const label = status.progress_label
@@ -641,6 +676,9 @@ function shouldShowUpdatePrompt(latestVersion: string): boolean {
async function loadVersionStatus(force = false) {
if (!isAdmin.value) return null
if (!force && applyCachedVersionStatus()) {
return versionStatus.value
}
if (versionStatusLoadPromise) return versionStatusLoadPromise
loadingVersionStatus.value = true
@@ -661,9 +699,11 @@ async function loadVersionStatus(force = false) {
}
: status
syncSystemUpdatePhase(versionStatus.value)
cacheVersionStatus(versionStatus.value)
return versionStatus.value
} catch (error) {
versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error)
cacheVersionStatus(versionStatus.value)
return versionStatus.value
} finally {
loadingVersionStatus.value = false
@@ -928,7 +968,10 @@ async function checkForUpdate() {
// 同一会话内只检查一次
const sessionKey = 'aether_update_checked'
if (sessionStorage.getItem(sessionKey)) return
if (sessionStorage.getItem(sessionKey)) {
applyCachedVersionStatus()
return
}
sessionStorage.setItem(sessionKey, '1')
const result = versionStatus.value ?? await loadVersionStatus()
@@ -973,12 +1016,20 @@ watch(
async function loadRequiredAnnouncements() {
if (!authStore.user || !authStore.token) return
try {
const response = await announcementApi.getRequiredUnreadAnnouncements()
requiredAnnouncements.value = response.items.filter(item => item.requires_ack && !item.is_read)
} catch {
requiredAnnouncements.value = []
}
if (requiredAnnouncementsPromise) return requiredAnnouncementsPromise
requiredAnnouncementsPromise = (async () => {
try {
const response = await announcementApi.getRequiredUnreadAnnouncements()
requiredAnnouncements.value = response.items.filter(item => item.requires_ack && !item.is_read)
} catch {
requiredAnnouncements.value = []
} finally {
requiredAnnouncementsPromise = null
}
})()
return requiredAnnouncementsPromise
}
function renderRequiredAnnouncement(content: string): string {
@@ -1005,16 +1056,19 @@ onMounted(() => {
window.addEventListener('storage', handleStorageChange)
document.addEventListener('visibilitychange', handleVisibilityChange)
syncAuthNotice()
applyCachedVersionStatus()
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
if (authStore.canAccessAdmin && !moduleStore.loaded && !moduleStore.loading) {
moduleStore.fetchModules()
void moduleStore.fetchModules().catch(() => {
// 路由守卫会在需要模块状态时按需处理失败场景。
})
}
void loadVersionStatus()
void loadRequiredAnnouncements()
// 延迟检查更新,避免影响页面加载
setTimeout(() => {
// 延迟检查更新,避免 GitHub Releases 检查和首屏业务数据争抢资源。
updateCheckTimer = window.setTimeout(() => {
updateCheckTimer = null
void checkForUpdate()
}, 2000)
@@ -1027,6 +1081,10 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange)
document.removeEventListener('visibilitychange', handleVisibilityChange)
if (updateCheckTimer !== null) {
window.clearTimeout(updateCheckTimer)
updateCheckTimer = null
}
stopUpdateStatusPolling()
if (import.meta.env.DEV && window.__aetherShowUpdateDialog === showDebugUpdateDialog) {
delete window.__aetherShowUpdateDialog
+19 -10
View File
@@ -9,25 +9,34 @@ export const useModuleStore = defineStore('modules', () => {
const loaded = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
let fetchModulesPromise: Promise<Record<string, ModuleStatus>> | null = null
/**
* 获取所有模块状态
*/
async function fetchModules() {
if (loading.value) return
if (fetchModulesPromise) return fetchModulesPromise
loading.value = true
error.value = null
try {
modules.value = await modulesApi.getAllStatus()
loaded.value = true
} catch (err: unknown) {
log.error('Failed to fetch modules status', err)
error.value = parseApiError(err, '获取模块状态失败')
} finally {
loading.value = false
}
fetchModulesPromise = (async () => {
try {
const nextModules = await modulesApi.getAllStatus()
modules.value = nextModules
loaded.value = true
return nextModules
} catch (err: unknown) {
log.error('Failed to fetch modules status', err)
error.value = parseApiError(err, '获取模块状态失败')
throw err
} finally {
loading.value = false
fetchModulesPromise = null
}
})()
return fetchModulesPromise
}
/**
+7 -72
View File
@@ -1,95 +1,30 @@
import { adminWalletApi } from '@/api/admin-wallets'
import { adminApi } from '@/api/admin'
import { adminBillingPlansApi, epayGatewayApi } from '@/api/billing'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getPoolOverview, getPoolSchedulingPresets, listPoolKeys } from '@/api/endpoints/pool'
import { listGlobalModels } from '@/api/global-models'
import { listRoutingGroups } from '@/api/routing-profiles'
import { usersApi } from '@/api/users'
import { log } from '@/utils/logger'
const NAV_DATA_CACHE_TTL_MS = 10 * 1000
const NAV_SYSTEM_CONFIG_CACHE_TTL_MS = 30 * 1000
const NAV_POOL_PRESETS_CACHE_TTL_MS = 5 * 60 * 1000
const PREFETCH_COOLDOWN_MS = 5 * 1000
const lastPrefetchAt = new Map<string, number>()
const adminRouteWarmers: Record<string, () => Promise<void>> = {
'/admin/users': async () => {
await Promise.allSettled([
import('@/views/admin/Users.vue'),
usersApi.getAllUsers({ cacheTtlMs: NAV_DATA_CACHE_TTL_MS }),
adminWalletApi.listAllWallets(
{ owner_type: 'user' },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
),
])
await import('@/views/admin/Users.vue')
},
'/admin/providers': async () => {
await Promise.allSettled([
import('@/views/admin/ProviderManagement.vue'),
getProvidersSummary(
{ page: 1, page_size: 20 },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
),
adminApi.getSystemConfig('provider_priority_mode', {
cacheTtlMs: NAV_SYSTEM_CONFIG_CACHE_TTL_MS,
}),
listGlobalModels(
{ is_active: true, limit: 1000 },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
),
])
await import('@/views/admin/ProviderManagement.vue')
},
'/admin/models': async () => {
await Promise.allSettled([
import('@/views/admin/ModelManagement.vue'),
listGlobalModels(
{ skip: 0, limit: 20 },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
),
])
await import('@/views/admin/ModelManagement.vue')
},
'/admin/routing': async () => {
await Promise.allSettled([
import('@/views/admin/RoutingProfiles.vue'),
listRoutingGroups(),
])
await import('@/views/admin/RoutingProfiles.vue')
},
'/admin/pool': async () => {
const [overviewResult] = await Promise.allSettled([
getPoolOverview({ cacheTtlMs: NAV_DATA_CACHE_TTL_MS }),
getPoolSchedulingPresets({ cacheTtlMs: NAV_POOL_PRESETS_CACHE_TTL_MS }),
import('@/views/admin/PoolManagement.vue'),
])
if (overviewResult.status !== 'fulfilled') {
return
}
const firstProviderId = overviewResult.value.items.find(item => item.pool_enabled)?.provider_id
if (!firstProviderId) {
return
}
await listPoolKeys(
firstProviderId,
{ page: 1, page_size: 50, status: 'all' },
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
)
await import('@/views/admin/PoolManagement.vue')
},
'/admin/payment-gateways': async () => {
await Promise.allSettled([
import('@/views/admin/PaymentGatewaySettings.vue'),
epayGatewayApi.get(),
])
await import('@/views/admin/PaymentGatewaySettings.vue')
},
'/admin/billing-plans': async () => {
await Promise.allSettled([
import('@/views/admin/BillingPlansManagement.vue'),
adminBillingPlansApi.list(),
])
await import('@/views/admin/BillingPlansManagement.vue')
},
}