perf(前端): 优化页面加载性能和用户体验

- 添加路由预加载功能,使用 requestIdleCallback 在浏览器空闲时预加载
- 配置 Vite manualChunks 分离 vendor 库(vue/ui/chart/i18n/misc)
- 新增 NavigationProgress 导航进度条组件,支持防闪烁和无障碍
- 集成 Vitest 测试框架,添加 40 个单元测试和集成测试
- 支持 prefers-reduced-motion 和暗色模式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-01-16 21:43:39 +08:00
parent eb432a49ed
commit 92234857f7
15 changed files with 4243 additions and 4 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,10 @@
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"typecheck": "vue-tsc --noEmit"
"typecheck": "vue-tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@lobehub/icons": "^4.0.2",
@@ -29,17 +32,21 @@
"@types/file-saver": "^2.0.7",
"@types/mdx": "^2.0.13",
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.16",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/coverage-v8": "^2.1.9",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.16",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.25.0",
"jsdom": "^24.1.3",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "~5.6.0",
"vite": "^5.0.10",
"vite-plugin-checker": "^0.9.1",
"vitest": "^2.1.9",
"vue-tsc": "^2.2.0"
}
}

937
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import { RouterView, useRouter, useRoute } from 'vue-router'
import { onMounted, watch } from 'vue'
import Toast from '@/components/common/Toast.vue'
import NavigationProgress from '@/components/common/NavigationProgress.vue'
import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores'
import { getSetupStatus } from '@/api/setup'
@@ -84,6 +85,7 @@ onMounted(async () => {
</script>
<template>
<NavigationProgress />
<RouterView />
<Toast />
</template>

View File

@@ -0,0 +1,478 @@
/**
* 导航集成测试
* 测试完整的页面导航流程、预加载和错误恢复机制
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createRouter, createWebHistory, type Router } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, nextTick } from 'vue'
import { useNavigationLoadingState, _resetNavigationLoadingInstance } from '@/composables/useNavigationLoading'
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
// Mock 视图组件
const MockDashboard = defineComponent({
name: 'MockDashboard',
render() {
return h('div', { class: 'dashboard' }, 'Dashboard')
}
})
const MockKeys = defineComponent({
name: 'MockKeys',
render() {
return h('div', { class: 'keys' }, 'Keys')
}
})
const MockUsage = defineComponent({
name: 'MockUsage',
render() {
return h('div', { class: 'usage' }, 'Usage')
}
})
// Mock stores
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
isAuthenticated: true,
isAdmin: false,
isSimpleMode: false,
checkAuth: vi.fn()
})
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
siteName: 'Test Site'
})
}))
// 创建测试路由
function createTestRouter(): Router {
return createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard,
meta: { requiresAuth: true, title: 'Dashboard' }
},
{
path: '/keys',
name: 'Keys',
component: MockKeys,
meta: { requiresAuth: true, title: 'Keys' }
},
{
path: '/usage',
name: 'Usage',
component: MockUsage,
meta: { requiresAuth: true, title: 'Usage' }
}
]
})
}
// 测试用 App 组件
const TestApp = defineComponent({
name: 'TestApp',
setup() {
return () => h('div', { id: 'app' }, [h('router-view')])
}
})
describe('Navigation Integration Tests', () => {
let router: Router
let originalRequestIdleCallback: typeof window.requestIdleCallback
let originalCancelIdleCallback: typeof window.cancelIdleCallback
beforeEach(() => {
// 设置 Pinia
setActivePinia(createPinia())
// 重置导航加载状态
_resetNavigationLoadingInstance()
// 创建新的路由实例
router = createTestRouter()
// Mock requestIdleCallback
originalRequestIdleCallback = window.requestIdleCallback
originalCancelIdleCallback = window.cancelIdleCallback
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => {
const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
return id
})
vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id))
})
afterEach(() => {
vi.restoreAllMocks()
window.requestIdleCallback = originalRequestIdleCallback
window.cancelIdleCallback = originalCancelIdleCallback
})
describe('完整页面导航流程', () => {
it('导航时应该触发加载状态变化', async () => {
const navigationLoading = useNavigationLoadingState()
// 初始状态
expect(navigationLoading.isLoading.value).toBe(false)
// 挂载应用
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
// 等待路由初始化
await router.isReady()
await flushPromises()
// 导航到 /dashboard
await router.push('/dashboard')
await flushPromises()
await nextTick()
// 导航结束后状态应该重置
expect(navigationLoading.isLoading.value).toBe(false)
wrapper.unmount()
})
it('导航到新页面应该正确渲染组件', async () => {
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
await router.push('/dashboard')
await flushPromises()
await nextTick()
// 检查当前路由
expect(router.currentRoute.value.path).toBe('/dashboard')
wrapper.unmount()
})
it('连续快速导航应该正确处理路由状态', async () => {
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
await router.push('/dashboard')
// 快速连续导航
router.push('/keys')
router.push('/usage')
router.push('/dashboard')
await flushPromises()
await nextTick()
// 应该最终停在 /dashboard
expect(router.currentRoute.value.path).toBe('/dashboard')
wrapper.unmount()
})
})
describe('路由预加载', () => {
it('导航后应该触发相关路由预加载', async () => {
const routePrefetch = useRoutePrefetch()
const triggerSpy = vi.spyOn(routePrefetch, 'triggerPrefetch')
// 设置 afterEach 守卫
router.afterEach((to) => {
routePrefetch.triggerPrefetch(to)
})
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
await router.push('/dashboard')
await flushPromises()
// 应该触发预加载
expect(triggerSpy).toHaveBeenCalled()
wrapper.unmount()
})
it('已预加载的路由不应重复预加载', async () => {
const routePrefetch = useRoutePrefetch()
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
await router.push('/dashboard')
await flushPromises()
// 手动触发预加载
routePrefetch.triggerPrefetch(router.currentRoute.value)
await new Promise((resolve) => setTimeout(resolve, 100))
const prefetchedCount = routePrefetch.prefetchedRoutes.value.size
// 再次触发相同路由预加载
routePrefetch.triggerPrefetch(router.currentRoute.value)
await new Promise((resolve) => setTimeout(resolve, 100))
// 预加载数量不应增加
expect(routePrefetch.prefetchedRoutes.value.size).toBe(prefetchedCount)
wrapper.unmount()
})
it('路由变化时应取消之前的预加载任务', async () => {
const routePrefetch = useRoutePrefetch()
const wrapper = mount(TestApp, {
global: {
plugins: [router]
}
})
await router.isReady()
// 触发预加载
routePrefetch.triggerPrefetch(router.currentRoute.value)
// 立即导航到新路由(这会在内部调用 cancelPendingPrefetch
routePrefetch.triggerPrefetch({ path: '/keys' } as any)
// 由于 triggerPrefetch 内部调用 cancelPendingPrefetch检查是否有预加载被正确管理
expect(routePrefetch.prefetchedRoutes.value.size).toBeLessThanOrEqual(2)
wrapper.unmount()
})
})
describe('Chunk 加载错误恢复', () => {
it('chunk 加载失败应该被正确捕获', async () => {
const errorHandler = vi.fn()
// 创建带错误处理的路由
const errorRouter = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard
},
{
path: '/error-page',
name: 'ErrorPage',
// 模拟加载失败的组件
component: () => Promise.reject(new Error('Failed to fetch dynamically imported module'))
}
]
})
errorRouter.onError(errorHandler)
const wrapper = mount(TestApp, {
global: {
plugins: [errorRouter]
}
})
await errorRouter.isReady()
await errorRouter.push('/dashboard')
await flushPromises()
// 尝试导航到会失败的页面
try {
await errorRouter.push('/error-page')
} catch {
// 预期会失败
}
await flushPromises()
// 错误处理器应该被调用
expect(errorHandler).toHaveBeenCalled()
wrapper.unmount()
})
it('chunk 加载错误应该包含正确的错误信息', async () => {
let capturedError: Error | null = null
const errorRouter = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard
},
{
path: '/chunk-error',
name: 'ChunkError',
component: () => {
const error = new Error('Loading chunk failed')
error.name = 'ChunkLoadError'
return Promise.reject(error)
}
}
]
})
errorRouter.onError((error) => {
capturedError = error
})
const wrapper = mount(TestApp, {
global: {
plugins: [errorRouter]
}
})
await errorRouter.isReady()
try {
await errorRouter.push('/chunk-error')
} catch {
// 预期会失败
}
await flushPromises()
expect(capturedError).not.toBeNull()
expect(capturedError!.name).toBe('ChunkLoadError')
wrapper.unmount()
})
})
describe('导航状态管理', () => {
it('导航开始时 isLoading 应该变为 true', async () => {
const navigationLoading = useNavigationLoadingState()
// 创建一个延迟加载的组件来模拟真实场景
const DelayedComponent = defineComponent({
name: 'DelayedComponent',
async setup() {
await new Promise((resolve) => setTimeout(resolve, 50))
return () => h('div', 'Delayed')
}
})
const delayRouter = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard
},
{
path: '/delayed',
name: 'Delayed',
component: DelayedComponent
}
]
})
// 设置导航守卫
delayRouter.beforeEach(() => {
navigationLoading.startNavigation()
})
delayRouter.afterEach(() => {
navigationLoading.endNavigation()
})
const wrapper = mount(TestApp, {
global: {
plugins: [delayRouter]
}
})
await delayRouter.isReady()
await delayRouter.push('/dashboard')
await flushPromises()
// 导航结束后 isLoading 应该为 false
expect(navigationLoading.isLoading.value).toBe(false)
wrapper.unmount()
})
it('导航取消时应该正确重置状态', async () => {
const navigationLoading = useNavigationLoadingState()
const testRouter = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
name: 'Dashboard',
component: MockDashboard
},
{
path: '/keys',
name: 'Keys',
component: MockKeys,
beforeEnter: (_to, _from, next) => {
// 模拟导航取消
next(false)
}
}
]
})
testRouter.beforeEach(() => {
navigationLoading.startNavigation()
})
testRouter.afterEach(() => {
navigationLoading.endNavigation()
})
const wrapper = mount(TestApp, {
global: {
plugins: [testRouter]
}
})
await testRouter.isReady()
await testRouter.push('/dashboard')
await flushPromises()
// 尝试导航到被取消的路由
await testRouter.push('/keys').catch(() => {})
await flushPromises()
// 导航被取消后,状态应该被重置
// 注意:由于 afterEach 仍然会被调用isLoading 应该为 false
expect(navigationLoading.isLoading.value).toBe(false)
wrapper.unmount()
})
})
})

View File

@@ -0,0 +1,45 @@
/**
* Vitest 测试环境设置
* 提供全局 mock 和测试工具
*/
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
// Mock requestIdleCallback (Safari < 15 不支持)
if (typeof globalThis.requestIdleCallback === 'undefined') {
globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => {
return window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 1)
}) as unknown as typeof requestIdleCallback
}
if (typeof globalThis.cancelIdleCallback === 'undefined') {
globalThis.cancelIdleCallback = ((id: number) => {
window.clearTimeout(id)
}) as unknown as typeof cancelIdleCallback
}
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver
// Mock ResizeObserver
class MockResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
// Vue Test Utils 全局配置
config.global.stubs = {
// 可以在这里添加全局 stub
}
// 设置全局测试超时
vi.setConfig({ testTimeout: 10000 })

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* 导航进度条组件
* 在页面顶部显示加载进度,提供导航反馈
*/
import { computed } from 'vue'
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
const { isLoading } = useNavigationLoadingState()
// 进度条可见性
const isVisible = computed(() => isLoading.value)
</script>
<template>
<Transition name="progress-fade">
<div
v-show="isVisible"
class="navigation-progress"
role="progressbar"
aria-label="Loading"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100"
>
<div class="navigation-progress-bar" />
</div>
</Transition>
</template>
<style scoped>
.navigation-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 9999;
overflow: hidden;
background: transparent;
}
.navigation-progress-bar {
height: 100%;
width: 100%;
background: linear-gradient(
90deg,
transparent 0%,
theme('colors.primary.400') 20%,
theme('colors.primary.500') 50%,
theme('colors.primary.400') 80%,
transparent 100%
);
animation: progress-slide 1.5s ease-in-out infinite;
}
/* 暗色模式下的进度条颜色 */
:root.dark .navigation-progress-bar {
background: linear-gradient(
90deg,
transparent 0%,
theme('colors.primary.500') 20%,
theme('colors.primary.400') 50%,
theme('colors.primary.500') 80%,
transparent 100%
);
}
/* 进度条滑动动画 */
@keyframes progress-slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* 淡入淡出过渡 */
.progress-fade-enter-active {
transition: opacity 0.15s ease-out;
}
.progress-fade-leave-active {
transition: opacity 0.3s ease-out;
}
.progress-fade-enter-from,
.progress-fade-leave-to {
opacity: 0;
}
/* 减少动画模式 */
@media (prefers-reduced-motion: reduce) {
.navigation-progress-bar {
animation: progress-pulse 2s ease-in-out infinite;
}
@keyframes progress-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,83 @@
/**
* NavigationProgress 组件单元测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import NavigationProgress from '../../common/NavigationProgress.vue'
// Mock useNavigationLoadingState
const mockIsLoading = ref(false)
vi.mock('@/composables/useNavigationLoading', () => ({
useNavigationLoadingState: () => ({
isLoading: mockIsLoading
})
}))
describe('NavigationProgress', () => {
beforeEach(() => {
mockIsLoading.value = false
})
it('isLoading=false 时进度条应该隐藏', () => {
mockIsLoading.value = false
const wrapper = mount(NavigationProgress)
const progressBar = wrapper.find('.navigation-progress')
// v-show 会设置 display: none
expect(progressBar.isVisible()).toBe(false)
})
it('isLoading=true 时进度条应该可见', async () => {
mockIsLoading.value = true
const wrapper = mount(NavigationProgress)
await wrapper.vm.$nextTick()
const progressBar = wrapper.find('.navigation-progress')
expect(progressBar.exists()).toBe(true)
expect(progressBar.isVisible()).toBe(true)
})
it('应该有正确的 ARIA 属性', () => {
mockIsLoading.value = true
const wrapper = mount(NavigationProgress)
const progressBar = wrapper.find('.navigation-progress')
expect(progressBar.attributes('role')).toBe('progressbar')
expect(progressBar.attributes('aria-label')).toBe('Loading')
expect(progressBar.attributes('aria-valuemin')).toBe('0')
expect(progressBar.attributes('aria-valuemax')).toBe('100')
})
it('进度条应该有动画 class', () => {
mockIsLoading.value = true
const wrapper = mount(NavigationProgress)
const bar = wrapper.find('.navigation-progress-bar')
expect(bar.exists()).toBe(true)
})
it('应该正确响应 isLoading 状态变化', async () => {
// 测试初始状态为 false
mockIsLoading.value = false
const wrapper = mount(NavigationProgress)
await wrapper.vm.$nextTick()
// 初始状态隐藏
expect(wrapper.find('.navigation-progress').isVisible()).toBe(false)
// 卸载后重新挂载以测试 true 状态
wrapper.unmount()
// 改变为 true 后重新挂载
mockIsLoading.value = true
const wrapper2 = mount(NavigationProgress)
await wrapper2.vm.$nextTick()
expect(wrapper2.find('.navigation-progress').isVisible()).toBe(true)
// 清理
wrapper2.unmount()
})
})

View File

@@ -0,0 +1,176 @@
/**
* useNavigationLoading 组合式函数单元测试
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
useNavigationLoading,
_resetNavigationLoadingInstance
} from '../useNavigationLoading'
describe('useNavigationLoading', () => {
beforeEach(() => {
vi.useFakeTimers()
_resetNavigationLoadingInstance()
})
afterEach(() => {
vi.useRealTimers()
})
describe('startNavigation', () => {
it('导航开始时 isNavigating 应变为 true', () => {
const { isNavigating, startNavigation } = useNavigationLoading()
expect(isNavigating.value).toBe(false)
startNavigation()
expect(isNavigating.value).toBe(true)
})
it('导航开始后延迟显示加载指示器(防闪烁)', () => {
const { isLoading, startNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
// 立即检查,不应该显示
expect(isLoading.value).toBe(false)
// 经过防闪烁延迟后应该显示
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(true)
})
})
describe('endNavigation', () => {
it('导航结束时 isLoading 应变为 false', () => {
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(true)
endNavigation()
expect(isLoading.value).toBe(false)
})
it('导航结束时 isNavigating 应变为 false', () => {
const { isNavigating, startNavigation, endNavigation } = useNavigationLoading()
startNavigation()
expect(isNavigating.value).toBe(true)
endNavigation()
expect(isNavigating.value).toBe(false)
})
})
describe('快速导航(< 100ms防闪烁', () => {
it('快速导航不应触发显示加载指示器', () => {
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
// 在防闪烁延迟之前结束导航
vi.advanceTimersByTime(ANTI_FLICKER_DELAY - 50)
endNavigation()
// 不应该显示加载指示器
expect(isLoading.value).toBe(false)
// 即使继续等待也不应该显示
vi.advanceTimersByTime(100)
expect(isLoading.value).toBe(false)
})
})
describe('cancelNavigation', () => {
it('导航取消时应正确重置状态', () => {
const { isLoading, startNavigation, cancelNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
vi.advanceTimersByTime(ANTI_FLICKER_DELAY / 2)
cancelNavigation()
// 取消后不应该触发显示
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(false)
})
})
describe('getNavigationDuration', () => {
it('应该返回正确的导航持续时间', () => {
const { startNavigation, getNavigationDuration } = useNavigationLoading()
expect(getNavigationDuration()).toBeNull()
startNavigation()
vi.advanceTimersByTime(500)
const duration = getNavigationDuration()
expect(duration).toBe(500)
})
it('导航结束后应返回 null', () => {
const { startNavigation, endNavigation, getNavigationDuration } = useNavigationLoading()
startNavigation()
vi.advanceTimersByTime(500)
endNavigation()
expect(getNavigationDuration()).toBeNull()
})
})
describe('resetState', () => {
it('应该重置所有状态', () => {
const { isLoading, isNavigating, startNavigation, resetState, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(true)
expect(isNavigating.value).toBe(true)
resetState()
expect(isLoading.value).toBe(false)
expect(isNavigating.value).toBe(false)
})
})
describe('连续导航场景', () => {
it('连续快速导航应正确处理状态', () => {
const { isLoading, startNavigation, cancelNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
// 第一次导航
startNavigation()
vi.advanceTimersByTime(30)
// 第二次导航(取消第一次)
cancelNavigation()
startNavigation()
vi.advanceTimersByTime(30)
// 第三次导航(取消第二次)
cancelNavigation()
startNavigation()
// 这次等待足够长时间
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(true)
// 结束导航
endNavigation()
expect(isLoading.value).toBe(false)
})
})
describe('ANTI_FLICKER_DELAY 常量', () => {
it('应该为 100ms', () => {
const { ANTI_FLICKER_DELAY } = useNavigationLoading()
expect(ANTI_FLICKER_DELAY).toBe(100)
})
})
})

View File

@@ -0,0 +1,219 @@
/**
* useRoutePrefetch 组合式函数单元测试
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { RouteLocationNormalized } from 'vue-router'
// Mock 所有动态 import
vi.mock('@/views/admin/AccountsView.vue', () => ({ default: {} }))
vi.mock('@/views/admin/UsersView.vue', () => ({ default: {} }))
vi.mock('@/views/admin/DashboardView.vue', () => ({ default: {} }))
vi.mock('@/views/admin/GroupsView.vue', () => ({ default: {} }))
vi.mock('@/views/admin/SubscriptionsView.vue', () => ({ default: {} }))
vi.mock('@/views/admin/RedeemView.vue', () => ({ default: {} }))
vi.mock('@/views/user/KeysView.vue', () => ({ default: {} }))
vi.mock('@/views/user/UsageView.vue', () => ({ default: {} }))
vi.mock('@/views/user/DashboardView.vue', () => ({ default: {} }))
vi.mock('@/views/user/RedeemView.vue', () => ({ default: {} }))
vi.mock('@/views/user/ProfileView.vue', () => ({ default: {} }))
import { useRoutePrefetch, _adminPrefetchMap, _userPrefetchMap } from '../useRoutePrefetch'
// Mock 路由对象
const createMockRoute = (path: string): RouteLocationNormalized => ({
path,
name: undefined,
params: {},
query: {},
hash: '',
fullPath: path,
matched: [],
meta: {},
redirectedFrom: undefined
})
describe('useRoutePrefetch', () => {
let originalRequestIdleCallback: typeof window.requestIdleCallback
let originalCancelIdleCallback: typeof window.cancelIdleCallback
beforeEach(() => {
// 保存原始函数
originalRequestIdleCallback = window.requestIdleCallback
originalCancelIdleCallback = window.cancelIdleCallback
// Mock requestIdleCallback 立即执行
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => {
const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
return id
})
vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id))
})
afterEach(() => {
vi.restoreAllMocks()
// 恢复原始函数
window.requestIdleCallback = originalRequestIdleCallback
window.cancelIdleCallback = originalCancelIdleCallback
})
describe('_isAdminRoute', () => {
it('应该正确识别管理员路由', () => {
const { _isAdminRoute } = useRoutePrefetch()
expect(_isAdminRoute('/admin/dashboard')).toBe(true)
expect(_isAdminRoute('/admin/users')).toBe(true)
expect(_isAdminRoute('/admin/accounts')).toBe(true)
})
it('应该正确识别非管理员路由', () => {
const { _isAdminRoute } = useRoutePrefetch()
expect(_isAdminRoute('/dashboard')).toBe(false)
expect(_isAdminRoute('/keys')).toBe(false)
expect(_isAdminRoute('/usage')).toBe(false)
})
})
describe('_getPrefetchConfig', () => {
it('管理员 dashboard 应该返回正确的预加载配置', () => {
const { _getPrefetchConfig } = useRoutePrefetch()
const route = createMockRoute('/admin/dashboard')
const config = _getPrefetchConfig(route)
expect(config).toHaveLength(2)
})
it('普通用户 dashboard 应该返回正确的预加载配置', () => {
const { _getPrefetchConfig } = useRoutePrefetch()
const route = createMockRoute('/dashboard')
const config = _getPrefetchConfig(route)
expect(config).toHaveLength(2)
})
it('未定义的路由应该返回空数组', () => {
const { _getPrefetchConfig } = useRoutePrefetch()
const route = createMockRoute('/unknown-route')
const config = _getPrefetchConfig(route)
expect(config).toHaveLength(0)
})
})
describe('triggerPrefetch', () => {
it('应该在浏览器空闲时触发预加载', async () => {
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
const route = createMockRoute('/admin/dashboard')
triggerPrefetch(route)
// 等待 requestIdleCallback 执行
await new Promise((resolve) => setTimeout(resolve, 100))
expect(prefetchedRoutes.value.has('/admin/dashboard')).toBe(true)
})
it('应该避免重复预加载同一路由', async () => {
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
const route = createMockRoute('/admin/dashboard')
triggerPrefetch(route)
await new Promise((resolve) => setTimeout(resolve, 100))
// 第二次触发
triggerPrefetch(route)
await new Promise((resolve) => setTimeout(resolve, 100))
// 只应该预加载一次
expect(prefetchedRoutes.value.size).toBe(1)
})
})
describe('cancelPendingPrefetch', () => {
it('应该取消挂起的预加载任务', () => {
const { triggerPrefetch, cancelPendingPrefetch, prefetchedRoutes } = useRoutePrefetch()
const route = createMockRoute('/admin/dashboard')
triggerPrefetch(route)
cancelPendingPrefetch()
// 不应该有预加载完成
expect(prefetchedRoutes.value.size).toBe(0)
})
})
describe('路由变化时取消之前的预加载', () => {
it('应该在路由变化时取消之前的预加载任务', async () => {
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
// 触发第一个路由的预加载
triggerPrefetch(createMockRoute('/admin/dashboard'))
// 立即切换到另一个路由
triggerPrefetch(createMockRoute('/admin/users'))
// 等待执行
await new Promise((resolve) => setTimeout(resolve, 100))
// 只有最后一个路由应该被预加载
expect(prefetchedRoutes.value.has('/admin/users')).toBe(true)
})
})
describe('resetPrefetchState', () => {
it('应该重置所有预加载状态', async () => {
const { triggerPrefetch, resetPrefetchState, prefetchedRoutes } = useRoutePrefetch()
const route = createMockRoute('/admin/dashboard')
triggerPrefetch(route)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(prefetchedRoutes.value.size).toBeGreaterThan(0)
resetPrefetchState()
expect(prefetchedRoutes.value.size).toBe(0)
})
})
describe('预加载映射表', () => {
it('管理员预加载映射表应该包含正确的路由', () => {
expect(_adminPrefetchMap).toHaveProperty('/admin/dashboard')
expect(_adminPrefetchMap['/admin/dashboard']).toHaveLength(2)
})
it('用户预加载映射表应该包含正确的路由', () => {
expect(_userPrefetchMap).toHaveProperty('/dashboard')
expect(_userPrefetchMap['/dashboard']).toHaveLength(2)
})
})
describe('requestIdleCallback 超时处理', () => {
it('超时后仍能正常执行预加载', async () => {
// 模拟超时情况
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback, options?: IdleRequestOptions) => {
const timeout = options?.timeout || 2000
return setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), timeout)
})
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
const route = createMockRoute('/dashboard')
triggerPrefetch(route)
// 等待超时执行
await new Promise((resolve) => setTimeout(resolve, 2100))
expect(prefetchedRoutes.value.has('/dashboard')).toBe(true)
})
})
describe('预加载失败处理', () => {
it('预加载失败时应该静默处理不影响页面功能', async () => {
// 这个测试验证预加载失败不会抛出异常
const { triggerPrefetch } = useRoutePrefetch()
const route = createMockRoute('/admin/dashboard')
// 不应该抛出异常
expect(() => triggerPrefetch(route)).not.toThrow()
})
})
})

View File

@@ -0,0 +1,132 @@
/**
* 导航加载状态组合式函数
* 管理路由切换时的加载状态,支持防闪烁逻辑
*/
import { ref, readonly, computed } from 'vue'
/**
* 导航加载状态管理
*
* 功能:
* 1. 在路由切换时显示加载状态
* 2. 快速导航(< 100ms不显示加载指示器防闪烁
* 3. 导航取消时正确重置状态
*/
export function useNavigationLoading() {
// 内部加载状态
const _isLoading = ref(false)
// 导航开始时间(用于防闪烁计算)
let navigationStartTime: number | null = null
// 防闪烁延迟计时器
let showLoadingTimer: ReturnType<typeof setTimeout> | null = null
// 是否应该显示加载指示器(考虑防闪烁逻辑)
const shouldShowLoading = ref(false)
// 防闪烁延迟时间(毫秒)
const ANTI_FLICKER_DELAY = 100
/**
* 清理计时器
*/
const clearTimer = (): void => {
if (showLoadingTimer !== null) {
clearTimeout(showLoadingTimer)
showLoadingTimer = null
}
}
/**
* 导航开始时调用
*/
const startNavigation = (): void => {
navigationStartTime = Date.now()
_isLoading.value = true
// 延迟显示加载指示器,实现防闪烁
clearTimer()
showLoadingTimer = setTimeout(() => {
if (_isLoading.value) {
shouldShowLoading.value = true
}
}, ANTI_FLICKER_DELAY)
}
/**
* 导航结束时调用
*/
const endNavigation = (): void => {
clearTimer()
_isLoading.value = false
shouldShowLoading.value = false
navigationStartTime = null
}
/**
* 导航取消时调用(比如快速连续点击不同链接)
*/
const cancelNavigation = (): void => {
clearTimer()
// 保持加载状态,因为新的导航会立即开始
// 但重置导航开始时间
navigationStartTime = null
}
/**
* 重置所有状态(用于测试)
*/
const resetState = (): void => {
clearTimer()
_isLoading.value = false
shouldShowLoading.value = false
navigationStartTime = null
}
/**
* 获取导航持续时间(毫秒)
*/
const getNavigationDuration = (): number | null => {
if (navigationStartTime === null) {
return null
}
return Date.now() - navigationStartTime
}
// 公开的加载状态(只读)
const isLoading = computed(() => shouldShowLoading.value)
// 内部加载状态(用于测试,不考虑防闪烁)
const isNavigating = readonly(_isLoading)
return {
isLoading,
isNavigating,
startNavigation,
endNavigation,
cancelNavigation,
resetState,
getNavigationDuration,
// 导出常量用于测试
ANTI_FLICKER_DELAY
}
}
// 创建单例实例,供全局使用
let navigationLoadingInstance: ReturnType<typeof useNavigationLoading> | null = null
export function useNavigationLoadingState() {
if (!navigationLoadingInstance) {
navigationLoadingInstance = useNavigationLoading()
}
return navigationLoadingInstance
}
// 导出重置函数(用于测试)
export function _resetNavigationLoadingInstance(): void {
if (navigationLoadingInstance) {
navigationLoadingInstance.resetState()
}
navigationLoadingInstance = null
}

View File

@@ -0,0 +1,304 @@
/**
* 路由预加载组合式函数
* 在浏览器空闲时预加载可能访问的下一个页面,提升导航体验
*/
import { ref, readonly } from 'vue'
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
/**
* 组件导入函数类型
*/
type ComponentImportFn = () => Promise<unknown>
/**
* 预加载配置类型
*/
interface PrefetchConfig {
[path: string]: ComponentImportFn[]
}
/**
* 路由预加载元数据扩展
* 在路由 meta 中可以指定 prefetch 配置
*/
declare module 'vue-router' {
interface RouteMeta {
/** 需要预加载的路由路径列表 */
prefetch?: string[]
}
}
/**
* requestIdleCallback 的返回类型
* 在支持的浏览器中返回 numberpolyfill 中使用 ReturnType<typeof setTimeout>
*/
type IdleCallbackHandle = number | ReturnType<typeof setTimeout>
/**
* requestIdleCallback polyfill
* Safari < 15 不支持 requestIdleCallback
*/
const scheduleIdleCallback = (
callback: IdleRequestCallback,
options?: IdleRequestOptions
): IdleCallbackHandle => {
if (typeof window.requestIdleCallback === 'function') {
return window.requestIdleCallback(callback, options)
}
// Fallback: 使用 setTimeout 模拟,延迟 1 秒执行
return setTimeout(() => {
callback({
didTimeout: false,
timeRemaining: () => 50
})
}, 1000)
}
const cancelScheduledCallback = (handle: IdleCallbackHandle): void => {
if (typeof window.cancelIdleCallback === 'function' && typeof handle === 'number') {
window.cancelIdleCallback(handle)
} else {
clearTimeout(handle)
}
}
/**
* 从路由配置自动生成预加载映射表
* 根据路由的 meta.prefetch 配置和同级路由自动生成
*
* @param routes - 路由配置数组
* @returns 预加载映射表
*/
export function generatePrefetchMap(routes: RouteRecordRaw[]): PrefetchConfig {
const prefetchMap: PrefetchConfig = {}
const routeComponentMap = new Map<string, ComponentImportFn>()
// 第一遍:收集所有路由的组件导入函数
const collectComponents = (routeList: RouteRecordRaw[], prefix = '') => {
for (const route of routeList) {
if (route.redirect) continue
const fullPath = prefix + route.path
if (route.component && typeof route.component === 'function') {
routeComponentMap.set(fullPath, route.component as ComponentImportFn)
}
// 递归处理子路由
if (route.children) {
collectComponents(route.children, fullPath)
}
}
}
collectComponents(routes)
// 第二遍:根据 meta.prefetch 或同级路由生成预加载映射
const generateMapping = (routeList: RouteRecordRaw[], siblings: RouteRecordRaw[] = []) => {
for (let i = 0; i < routeList.length; i++) {
const route = routeList[i]
if (route.redirect || !route.component) continue
const path = route.path
const prefetchPaths: string[] = []
// 优先使用 meta.prefetch 配置
if (route.meta?.prefetch && Array.isArray(route.meta.prefetch)) {
prefetchPaths.push(...route.meta.prefetch)
} else {
// 自动预加载相邻的同级路由(前后各一个)
const siblingRoutes = siblings.length > 0 ? siblings : routeList
const currentIndex = siblingRoutes.findIndex((r) => r.path === path)
if (currentIndex > 0) {
const prev = siblingRoutes[currentIndex - 1]
if (prev && !prev.redirect && prev.component) {
prefetchPaths.push(prev.path)
}
}
if (currentIndex < siblingRoutes.length - 1) {
const next = siblingRoutes[currentIndex + 1]
if (next && !next.redirect && next.component) {
prefetchPaths.push(next.path)
}
}
}
// 转换为组件导入函数
const importFns: ComponentImportFn[] = []
for (const prefetchPath of prefetchPaths) {
const importFn = routeComponentMap.get(prefetchPath)
if (importFn) {
importFns.push(importFn)
}
}
if (importFns.length > 0) {
prefetchMap[path] = importFns
}
// 递归处理子路由
if (route.children) {
generateMapping(route.children, route.children)
}
}
}
// 分别处理用户路由和管理员路由
const userRoutes = routes.filter(
(r) => !r.path.startsWith('/admin') && !r.path.startsWith('/auth') && !r.path.startsWith('/setup')
)
const adminRoutes = routes.filter((r) => r.path.startsWith('/admin'))
generateMapping(userRoutes, userRoutes)
generateMapping(adminRoutes, adminRoutes)
return prefetchMap
}
/**
* 默认预加载映射表(手动配置,优先级更高)
* 可以覆盖自动生成的映射
*/
const defaultAdminPrefetchMap: PrefetchConfig = {
'/admin/dashboard': [
() => import('@/views/admin/AccountsView.vue'),
() => import('@/views/admin/UsersView.vue')
],
'/admin/accounts': [
() => import('@/views/admin/DashboardView.vue'),
() => import('@/views/admin/UsersView.vue')
],
'/admin/users': [
() => import('@/views/admin/GroupsView.vue'),
() => import('@/views/admin/DashboardView.vue')
]
}
const defaultUserPrefetchMap: PrefetchConfig = {
'/dashboard': [
() => import('@/views/user/KeysView.vue'),
() => import('@/views/user/UsageView.vue')
],
'/keys': [
() => import('@/views/user/DashboardView.vue'),
() => import('@/views/user/UsageView.vue')
],
'/usage': [
() => import('@/views/user/KeysView.vue'),
() => import('@/views/user/RedeemView.vue')
]
}
/**
* 路由预加载组合式函数
*
* @param customPrefetchMap - 自定义预加载映射表(可选)
*/
export function useRoutePrefetch(customPrefetchMap?: PrefetchConfig) {
// 合并预加载映射表:自定义 > 默认管理员 > 默认用户
const prefetchMap: PrefetchConfig = {
...defaultUserPrefetchMap,
...defaultAdminPrefetchMap,
...customPrefetchMap
}
// 当前挂起的预加载任务句柄
const pendingPrefetchHandle = ref<IdleCallbackHandle | null>(null)
// 已预加载的路由集合(避免重复预加载)
const prefetchedRoutes = ref<Set<string>>(new Set())
/**
* 判断是否为管理员路由
*/
const isAdminRoute = (path: string): boolean => {
return path.startsWith('/admin')
}
/**
* 获取当前路由对应的预加载配置
*/
const getPrefetchConfig = (route: RouteLocationNormalized): ComponentImportFn[] => {
return prefetchMap[route.path] || []
}
/**
* 执行单个组件的预加载
* 静默处理错误,不影响页面功能
*/
const prefetchComponent = async (importFn: ComponentImportFn): Promise<void> => {
try {
await importFn()
} catch (error) {
// 静默处理预加载错误
if (import.meta.env.DEV) {
console.debug('[Prefetch] Failed to prefetch component:', error)
}
}
}
/**
* 取消挂起的预加载任务
*/
const cancelPendingPrefetch = (): void => {
if (pendingPrefetchHandle.value !== null) {
cancelScheduledCallback(pendingPrefetchHandle.value)
pendingPrefetchHandle.value = null
}
}
/**
* 触发路由预加载
* 在浏览器空闲时执行,超时 2 秒后强制执行
*/
const triggerPrefetch = (route: RouteLocationNormalized): void => {
// 取消之前的预加载任务
cancelPendingPrefetch()
const prefetchList = getPrefetchConfig(route)
if (prefetchList.length === 0) {
return
}
// 在浏览器空闲时执行预加载
pendingPrefetchHandle.value = scheduleIdleCallback(
() => {
pendingPrefetchHandle.value = null
// 过滤掉已预加载的组件
const routePath = route.path
if (prefetchedRoutes.value.has(routePath)) {
return
}
// 执行预加载
Promise.all(prefetchList.map(prefetchComponent)).then(() => {
prefetchedRoutes.value.add(routePath)
})
},
{ timeout: 2000 } // 2 秒超时
)
}
/**
* 重置预加载状态(用于测试)
*/
const resetPrefetchState = (): void => {
cancelPendingPrefetch()
prefetchedRoutes.value.clear()
}
return {
prefetchedRoutes: readonly(prefetchedRoutes),
triggerPrefetch,
cancelPendingPrefetch,
resetPrefetchState,
// 导出用于测试
_getPrefetchConfig: getPrefetchConfig,
_isAdminRoute: isAdminRoute
}
}
// 导出预加载映射表(用于测试)
export const _adminPrefetchMap = defaultAdminPrefetchMap
export const _userPrefetchMap = defaultUserPrefetchMap

View File

@@ -6,6 +6,8 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
/**
* Route definitions with lazy loading
@@ -326,7 +328,14 @@ const router = createRouter({
*/
let authInitialized = false
// 初始化导航加载状态和预加载
const navigationLoading = useNavigationLoadingState()
const routePrefetch = useRoutePrefetch()
router.beforeEach((to, _from, next) => {
// 开始导航加载状态
navigationLoading.startNavigation()
const authStore = useAuthStore()
// Restore auth state from localStorage on first navigation (page refresh)
@@ -398,6 +407,17 @@ router.beforeEach((to, _from, next) => {
next()
})
/**
* Navigation guard: End loading and trigger prefetch
*/
router.afterEach((to) => {
// 结束导航加载状态
navigationLoading.endNavigation()
// 触发路由预加载(在浏览器空闲时执行)
routePrefetch.triggerPrefetch(to)
})
/**
* Navigation guard: Error handling
* Handles dynamic import failures caused by deployment updates

View File

@@ -58,7 +58,49 @@ export default defineConfig({
},
build: {
outDir: '../backend/internal/web/dist',
emptyOutDir: true
emptyOutDir: true,
rollupOptions: {
output: {
/**
* 手动分包配置
* 分离第三方库并按功能合并应用代码,避免循环依赖
*/
manualChunks(id: string) {
if (id.includes('node_modules')) {
// Vue 核心库
if (
id.includes('/vue/') ||
id.includes('/vue-router/') ||
id.includes('/pinia/') ||
id.includes('/@vue/')
) {
return 'vendor-vue'
}
// UI 工具库(较大,单独分离)
if (id.includes('/@vueuse/') || id.includes('/xlsx/')) {
return 'vendor-ui'
}
// 图表库
if (id.includes('/chart.js/') || id.includes('/vue-chartjs/')) {
return 'vendor-chart'
}
// 国际化
if (id.includes('/vue-i18n/') || id.includes('/@intlify/')) {
return 'vendor-i18n'
}
// 其他小型第三方库合并
return 'vendor-misc'
}
// 应用代码:按入口点自动分包,不手动干预
// 这样可以避免循环依赖,同时保持合理的 chunk 数量
}
}
}
},
server: {
host: '0.0.0.0',

35
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,35 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{js,ts,vue}'],
exclude: [
'node_modules',
'src/**/*.d.ts',
'src/**/*.spec.ts',
'src/**/*.test.ts',
'src/main.ts'
],
thresholds: {
global: {
statements: 80,
branches: 80,
functions: 80,
lines: 80
}
}
},
setupFiles: ['./src/__tests__/setup.ts']
}
})
)