perf: 优化管理端生产加载体验

- 图标与重型组件按需加载,优化菜单预取和页面请求链路

- 开启 gzip 与静态缓存并修复子路由 KeepAlive 冲突
This commit is contained in:
2026-07-22 20:35:55 +08:00
parent 9f06c238d3
commit 19059fde96
27 changed files with 750 additions and 233 deletions

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { isLocalIcon, loadIconCollection } from './index';
describe('offline icon collections', () => {
it('keeps common icons in the startup registry', () => {
expect(isLocalIcon('mdi:github')).toBe(true);
expect(isLocalIcon('ant-design:appstore-outlined')).toBe(true);
});
it('loads uncommon icons from a bundled collection on demand', async () => {
expect(isLocalIcon('mdi:ab-testing')).toBe(false);
await loadIconCollection('mdi');
expect(isLocalIcon('mdi:ab-testing')).toBe(true);
});
});

View File

@@ -1,21 +1,40 @@
import antDesign from '@iconify/json/json/ant-design.json';
import ep from '@iconify/json/json/ep.json';
import mdi from '@iconify/json/json/mdi.json';
import {
addCollection as addOfflineCollection,
addIcon as addOfflineIcon,
Icon as IconifyIcon,
} from '@iconify/vue/offline';
import type {
IconifyIcon as IconifyIconData,
IconifyJSON,
} from '@iconify/vue/offline';
import {
addCollection as addOfflineCollection,
addIcon as addOfflineIcon,
Icon as IconifyIcon,
} from '@iconify/vue/offline';
import { LOCAL_ICON_DATA } from './local-icons';
import { STARTUP_ICON_DATA } from './startup-icons';
type LazyCollectionPrefix = 'ant-design' | 'ep' | 'mdi';
const LOCAL_ICON_NAMES = new Set<string>();
const COLLECTION_LOADERS: Record<
LazyCollectionPrefix,
() => Promise<IconifyJSON>
> = {
'ant-design': async () => {
const collection = await import('@iconify/json/json/ant-design.json');
return collection.default;
},
ep: async () => {
const collection = await import('@iconify/json/json/ep.json');
return collection.default;
},
mdi: async () => {
const collection = await import('@iconify/json/json/mdi.json');
return collection.default;
},
};
const COLLECTION_LOAD_PROMISES = new Map<LazyCollectionPrefix, Promise<void>>();
function getCollectionPrefix(data: IconifyJSON, prefix?: string | boolean) {
function getCollectionPrefix(data: IconifyJSON, prefix?: boolean | string) {
if (prefix === false) {
return '';
}
@@ -25,7 +44,7 @@ function getCollectionPrefix(data: IconifyJSON, prefix?: string | boolean) {
/**
* Register an icon collection in the offline Iconify store and local name index.
*/
function registerCollection(data: IconifyJSON, prefix?: string | boolean) {
function registerCollection(data: IconifyJSON, prefix?: boolean | string) {
addOfflineCollection(data, prefix);
const iconPrefix = getCollectionPrefix(data, prefix);
Object.keys(data.icons).forEach((name) => {
@@ -62,12 +81,66 @@ function isLocalIcon(name: string) {
return LOCAL_ICON_NAMES.has(name);
}
registerCollection(antDesign);
registerCollection(ep);
registerCollection(mdi);
Object.entries(LOCAL_ICON_DATA).forEach(([name, data]) => {
registerIcon(name, data);
});
function isLazyCollectionPrefix(
prefix: string,
): prefix is LazyCollectionPrefix {
return Object.prototype.hasOwnProperty.call(COLLECTION_LOADERS, prefix);
}
/**
* Load a bundled offline icon collection once, without using a remote API.
*/
async function loadIconCollection(prefix: string) {
if (!isLazyCollectionPrefix(prefix)) {
return listIcons('', prefix);
}
let pending = COLLECTION_LOAD_PROMISES.get(prefix);
if (!pending) {
pending = COLLECTION_LOADERS[prefix]()
.then((data) => {
registerCollection(data);
})
.catch((error) => {
COLLECTION_LOAD_PROMISES.delete(prefix);
throw error;
});
COLLECTION_LOAD_PROMISES.set(prefix, pending);
}
await pending;
return listIcons('', prefix);
}
/**
* Preserve historical menu icons by loading only the collections they need.
*/
async function loadMissingIconCollections(iconNames: string[]) {
const prefixes = new Set<LazyCollectionPrefix>();
iconNames.forEach((name) => {
if (isLocalIcon(name)) {
return;
}
const prefix = name.split(':', 1)[0] ?? '';
if (isLazyCollectionPrefix(prefix)) {
prefixes.add(prefix);
}
});
await Promise.all(
[...prefixes].map(async (prefix) => {
try {
await loadIconCollection(prefix);
} catch (error) {
console.error(`Failed to load menu icon collection: ${prefix}`, error);
}
}),
);
}
Object.entries({ ...LOCAL_ICON_DATA, ...STARTUP_ICON_DATA }).forEach(
([name, data]) => {
registerIcon(name, data);
},
);
export * from './create-icon';
@@ -75,9 +148,11 @@ export * from './lucide';
export type { IconifyIcon as IconifyIconStructure } from '@iconify/vue/offline';
export {
registerCollection as addCollection,
registerIcon as addIcon,
IconifyIcon,
isLocalIcon,
listIcons,
registerCollection as addCollection,
registerIcon as addIcon,
loadIconCollection,
loadMissingIconCollections,
};

View File

@@ -0,0 +1,68 @@
import type { IconifyIcon } from '@iconify/vue/offline';
/**
* Icons referenced directly by application source or default persisted menus.
* Full icon collections are loaded lazily when an uncommon icon is requested.
*/
export const STARTUP_ICON_DATA: Record<string, IconifyIcon> = {
'ant-design:apartment-outlined': {
width: 1024,
height: 1024,
body: '<path fill="currentColor" d="M908 640H804V488c0-4.4-3.6-8-8-8H548v-96h108c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16H368c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h108v96H228c-4.4 0-8 3.6-8 8v152H116c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h288c8.8 0 16-7.2 16-16V656c0-8.8-7.2-16-16-16H292v-88h440v88H620c-8.8 0-16 7.2-16 16v288c0 8.8 7.2 16 16 16h288c8.8 0 16-7.2 16-16V656c0-8.8-7.2-16-16-16m-564 76v168H176V716zm84-408V140h168v168zm420 576H680V716h168z"/>',
},
'ant-design:appstore-outlined': {
width: 1024,
height: 1024,
body: '<path fill="currentColor" d="M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16m-52 268H212V212h200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16m-52 268H612V212h200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16m-52 268H212V612h200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16m-52 268H612V612h200z"/>',
},
'ep:arrow-right': {
width: 1024,
height: 1024,
body: '<path fill="currentColor" d="M340.864 149.312a30.59 30.59 0 0 0 0 42.752L652.736 512L340.864 831.872a30.59 30.59 0 0 0 0 42.752a29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.376a29.12 29.12 0 0 0-41.728 0z"/>',
},
'ep:expand': {
width: 1024,
height: 1024,
body: '<path fill="currentColor" d="M128 192h768v128H128zm0 256h512v128H128zm0 256h768v128H128zm576-352l192 160l-192 128z"/>',
},
'ep:fold': {
width: 1024,
height: 1024,
body: '<path fill="currentColor" d="M896 192H128v128h768zm0 256H384v128h512zm0 256H128v128h768zM320 384L128 512l192 128z"/>',
},
'mdi:chat-outline': {
width: 24,
height: 24,
body: '<path fill="currentColor" d="M12 3C6.5 3 2 6.58 2 11a7.22 7.22 0 0 0 2.75 5.5c0 .6-.42 2.17-2.75 4.5c2.37-.11 4.64-1 6.47-2.5c1.14.33 2.34.5 3.53.5c5.5 0 10-3.58 10-8s-4.5-8-10-8m0 14c-4.42 0-8-2.69-8-6s3.58-6 8-6s8 2.69 8 6s-3.58 6-8 6"/>',
},
'mdi:clock-time-five-outline': {
width: 24,
height: 24,
body: '<path fill="currentColor" d="M12 20c4.4 0 8-3.6 8-8s-3.6-8-8-8s-8 3.6-8 8s3.6 8 8 8m0-18c5.5 0 10 4.5 10 10s-4.5 10-10 10S2 17.5 2 12S6.5 2 12 2m3.3 14.2L14 17l-3-5.2V7h1.5v4.4z"/>',
},
'mdi:github': {
width: 24,
height: 24,
body: '<path fill="currentColor" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"/>',
},
'mdi:hammer': {
width: 24,
height: 24,
body: '<path fill="currentColor" d="M2 19.63L13.43 8.2l-.71-.7l1.42-1.43L12 3.89c1.2-1.19 3.09-1.19 4.27 0l3.6 3.61l-1.42 1.41h2.84l.71.71l-3.55 3.59l-.71-.71V9.62l-1.47 1.42l-.71-.71L4.13 21.76z"/>',
},
'mdi:home-outline': {
width: 24,
height: 24,
body: '<path fill="currentColor" d="m12 5.69l5 4.5V18h-2v-6H9v6H7v-7.81zM12 3L2 12h3v8h6v-6h2v6h6v-8h3"/>',
},
'mdi:image-outline': {
width: 24,
height: 24,
body: '<path fill="currentColor" d="M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-5.04 9.29l-2.75 3.54l-1.96-2.36L6.5 17h11z"/>',
},
'mdi:keyboard-esc': {
width: 24,
height: 24,
body: '<path fill="currentColor" d="M1 7h6v2H3v2h4v2H3v2h4v2H1zm10 0h4v2h-4v2h2a2 2 0 0 1 2 2v2c0 1.11-.89 2-2 2H9v-2h4v-2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2m8 0h2a2 2 0 0 1 2 2v1h-2V9h-2v6h2v-1h2v1c0 1.11-.89 2-2 2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2"/>',
},
};

View File

@@ -91,6 +91,7 @@ onBeforeUnmount(() => {
</script>
<template>
<li
:data-menu-path="path"
:class="[
rootMenu.theme,
b(),

View File

@@ -50,6 +50,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
close: [string, string[]];
enter: [string];
open: [string, string[]];
select: [string, string[]];
}>();
@@ -257,6 +258,25 @@ function handleMenuItemClick(data: MenuItemClicked) {
emit('select', path, parentPaths);
}
let enteredPath = '';
function handleMenuItemEnter(event: Event) {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const menuItem = target.closest<HTMLElement>('[data-menu-path]');
if (!menuItem || !menu.value?.contains(menuItem)) {
return;
}
const path = menuItem.dataset.menuPath;
if (!path || path === enteredPath) {
return;
}
enteredPath = path;
emit('enter', path);
}
function handleSubMenuClick({ parentPaths, path }: MenuItemRegistered) {
const isOpened = openedMenus.value.includes(path);
@@ -349,6 +369,9 @@ function getActivePaths() {
]"
:style="menuStyle"
role="menu"
@focusin="handleMenuItemEnter"
@mouseleave="enteredPath = ''"
@mouseover="handleMenuItemEnter"
>
<template v-if="mode === 'horizontal' && getSlot.showSlotMore">
<template v-for="item in getSlot.slotDefault" :key="item.key">