mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
perf(frontend): 收敛导航预取并去重首屏请求
- 导航预取仅保留 pointerdown 触发,移除 mouseenter/focus,避免鼠标划过误触发 - 后台预取只做组件懒加载,不再预取各页业务数据,减少首屏资源争抢 - 版本状态检查增加 sessionStorage 缓存(正常 20 分钟 / 错误 5 分钟 TTL) - fetchModules、必读公告拉取增加请求去重,避免并发重复请求 - 更新检查改用可清理的定时器,组件卸载时清理 - UsageRecordsTable 搜索防抖改为自定义实现,卸载时取消挂起 emit 并补充测试
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(\"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\")` }"
|
||||
/>
|
||||
|
||||
<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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user