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

@@ -4,7 +4,12 @@ import type { VNode } from 'vue';
import { computed, ref, useAttrs, watch, watchEffect } from 'vue';
import { usePagination } from '@easyflow/hooks';
import { EmptyIcon, Grip, listIcons } from '@easyflow/icons';
import {
EmptyIcon,
Grip,
listIcons,
loadIconCollection,
} from '@easyflow/icons';
import { $t } from '@easyflow/locales';
import {
@@ -75,6 +80,30 @@ const currentPage = ref(1);
const keyword = ref('');
const keywordDebounce = refDebounced(keyword, 300);
const innerIcons = ref<string[]>([]);
let iconLoadRequestId = 0;
const commonSvgIcons = [
'svg:talk',
'svg:plugin',
'svg:workflow',
'svg:approval',
'svg:knowledge',
'svg:resource',
'svg:data-center',
'svg:llm',
'svg:account',
'svg:role',
'svg:menu',
'svg:department',
'svg:time',
'svg:log',
'svg:chat-history',
'svg:setting',
'svg:api',
'svg:user-feedback',
'svg:oauth',
'svg:mcp',
];
/* 当检索关键词变化时,重置分页 */
watch(keywordDebounce, () => {
@@ -82,36 +111,39 @@ watch(keywordDebounce, () => {
setCurrentPage(1);
});
watchDebounced(
() => props.prefix,
async (prefix) => {
if (prefix && prefix !== 'svg' && props.autoFetchApi) {
innerIcons.value = [
'svg:talk',
'svg:plugin',
'svg:workflow',
'svg:approval',
'svg:knowledge',
'svg:resource',
'svg:data-center',
'svg:llm',
'svg:account',
'svg:role',
'svg:menu',
'svg:department',
'svg:time',
'svg:log',
'svg:chat-history',
'svg:setting',
'svg:api',
'svg:user-feedback',
'svg:oauth',
'svg:mcp',
...getLocalIconsData(prefix),
];
async function refreshInnerIcons(prefix?: string) {
if (!prefix || prefix === 'svg' || !props.autoFetchApi) {
return;
}
const requestId = ++iconLoadRequestId;
try {
await loadIconCollection(prefix);
} catch (error) {
console.error(`Failed to load icon collection: ${prefix}`, error);
}
if (requestId === iconLoadRequestId && prefix === props.prefix) {
innerIcons.value = [...commonSvgIcons, ...getLocalIconsData(prefix, true)];
}
}
watch(
visible,
(isVisible) => {
if (isVisible) {
void refreshInnerIcons(props.prefix);
}
},
{ immediate: true, debounce: 500, maxWait: 1000 },
{ immediate: true },
);
watchDebounced(
() => props.prefix,
(prefix) => {
if (visible.value) {
void refreshInnerIcons(prefix);
}
},
{ debounce: 300, maxWait: 600 },
);
const currentList = computed(() => {

View File

@@ -12,8 +12,8 @@ export const ICONS_MAP: Recordable<string[]> = {};
* @param prefix 图标集名称
* @returns 图标集中包含的所有图标名称
*/
export function getLocalIconsData(prefix: string): string[] {
if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
export function getLocalIconsData(prefix: string, refresh = false): string[] {
if (!refresh && Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
return ICONS_MAP[prefix];
}
ICONS_MAP[prefix] = listIcons('', prefix);

View File

@@ -103,6 +103,7 @@ const showHeaderNav = computed(() => {
});
const {
handleMenuEnter,
handleMenuSelect,
handleMenuOpen,
headerActive,
@@ -302,6 +303,7 @@ const headerSlots = computed(() => {
:theme="headerTheme"
class="w-full"
mode="horizontal"
@enter="handleMenuEnter"
@select="handleMenuSelect"
/>
</template>
@@ -330,6 +332,7 @@ const headerSlots = computed(() => {
:rounded="isMenuRounded"
:theme="sidebarTheme"
mode="vertical"
@enter="handleMenuEnter"
@open="handleMenuOpen"
@select="handleMenuSelect"
/>

View File

@@ -15,6 +15,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const emit = defineEmits<{
enter: [string];
open: [string, string[]];
select: [string, string?];
}>();
@@ -26,6 +27,10 @@ function handleMenuSelect(key: string) {
function handleMenuOpen(key: string, path: string[]) {
emit('open', key, path);
}
function handleMenuEnter(key: string) {
emit('enter', key);
}
</script>
<template>
@@ -39,6 +44,7 @@ function handleMenuOpen(key: string, path: string[]) {
:rounded="rounded"
scroll-to-active
:theme="theme"
@enter="handleMenuEnter"
@open="handleMenuOpen"
@select="handleMenuSelect"
/>

View File

@@ -13,7 +13,7 @@ import { useNavigation } from './use-navigation';
function useExtraMenu(useRootMenus?: ComputedRef<MenuRecordRaw[]>) {
const accessStore = useAccessStore();
const { navigation, prefetch, willOpenedByWindow } = useNavigation();
const { navigation, schedulePrefetch, willOpenedByWindow } = useNavigation();
const menus = computed(() => useRootMenus?.value ?? accessStore.accessMenus);
@@ -87,7 +87,7 @@ function useExtraMenu(useRootMenus?: ComputedRef<MenuRecordRaw[]>) {
};
const handleMenuMouseEnter = (menu: MenuRecordRaw) => {
prefetch(menu.path);
schedulePrefetch(menu.children?.[0]?.path ?? menu.path);
if (!preferences.sidebar.expandOnHover) {
const { findMenu } = findRootMenuByPath(menus.value, menu.path);

View File

@@ -9,8 +9,12 @@ import { findRootMenuByPath } from '@easyflow/utils';
import { useNavigation } from './use-navigation';
const MAX_AUTOMATIC_PREFETCH_COUNT = 3;
const PREFETCH_INTERVAL = 120;
function useMixedMenu() {
const { navigation, prefetch, willOpenedByWindow } = useNavigation();
const { navigation, prefetch, schedulePrefetch, willOpenedByWindow } =
useNavigation();
const accessStore = useAccessStore();
const route = useRoute();
const splitSideMenus = ref<MenuRecordRaw[]>([]);
@@ -97,11 +101,13 @@ function useMixedMenu() {
if (!willOpenedByWindow(key)) {
rootMenuPath.value = rootMenu?.path ?? '';
splitSideMenus.value = _splitSideMenus;
_splitSideMenus.forEach((menu) => {
if (menu.path) {
prefetch(menu.path);
}
});
_splitSideMenus
.slice(0, MAX_AUTOMATIC_PREFETCH_COUNT)
.forEach((menu, index) => {
if (menu.path) {
schedulePrefetch(menu.path, PREFETCH_INTERVAL * (index + 1));
}
});
}
if (_splitSideMenus.length === 0) {
@@ -130,6 +136,10 @@ function useMixedMenu() {
}
};
const handleMenuEnter = (key: string) => {
schedulePrefetch(key);
};
/**
* 计算侧边菜单
* @param path 路由路径
@@ -166,6 +176,7 @@ function useMixedMenu() {
});
return {
handleMenuEnter,
handleMenuSelect,
handleMenuOpen,
headerActive,

View File

@@ -4,11 +4,37 @@ import { useRouter } from 'vue-router';
import { isHttpUrl, openRouteInNewWindow, openWindow } from '@easyflow/utils';
interface NetworkConnectionLike {
effectiveType?: string;
saveData?: boolean;
}
function canSpeculativelyPrefetch() {
if (typeof navigator === 'undefined') {
return false;
}
const nav = navigator as Navigator & {
connection?: NetworkConnectionLike;
mozConnection?: NetworkConnectionLike;
webkitConnection?: NetworkConnectionLike;
};
const connection =
nav.connection ?? nav.mozConnection ?? nav.webkitConnection;
if (!connection) {
return true;
}
return (
!connection.saveData &&
!['2g', '3g', 'slow-2g'].includes(connection.effectiveType ?? '')
);
}
function useNavigation() {
const router = useRouter();
const routeMetaMap = new Map<string, RouteRecordNormalized>();
const prefetchedPaths = new Set<string>();
const prefetchingPaths = new Set<string>();
const scheduledPrefetches = new Map<string, ReturnType<typeof setTimeout>>();
// 初始化路由映射
const initRouteMetaMap = () => {
@@ -40,6 +66,12 @@ function useNavigation() {
};
const prefetch = (path: string) => {
const scheduledPrefetch = scheduledPrefetches.get(path);
if (scheduledPrefetch) {
clearTimeout(scheduledPrefetch);
scheduledPrefetches.delete(path);
}
if (
isHttpUrl(path) ||
prefetchedPaths.has(path) ||
@@ -84,6 +116,24 @@ function useNavigation() {
});
};
const schedulePrefetch = (path: string, delay = 120) => {
if (
!canSpeculativelyPrefetch() ||
isHttpUrl(path) ||
prefetchedPaths.has(path) ||
prefetchingPaths.has(path) ||
scheduledPrefetches.has(path)
) {
return;
}
const timer = setTimeout(() => {
scheduledPrefetches.delete(path);
prefetch(path);
}, delay);
scheduledPrefetches.set(path, timer);
};
const navigation = async (path: string) => {
try {
const route = routeMetaMap.get(path);
@@ -116,7 +166,7 @@ function useNavigation() {
return shouldOpenInNewWindow(path);
};
return { navigation, prefetch, willOpenedByWindow };
return { navigation, prefetch, schedulePrefetch, willOpenedByWindow };
}
export { useNavigation };

View File

@@ -11,6 +11,10 @@
"types": "./src/echarts/index.ts",
"default": "./src/echarts/index.ts"
},
"./echarts/workspace": {
"types": "./src/echarts/workspace.ts",
"default": "./src/echarts/workspace.ts"
},
"./vxe-table": {
"types": "./src/vxe-table/index.ts",
"default": "./src/vxe-table/index.ts"

View File

@@ -0,0 +1,149 @@
import type { EChartsOption } from 'echarts';
import type { EChartsType } from 'echarts/core';
import type { Ref } from 'vue';
import type { Nullable } from '@easyflow/types';
import type EchartsUI from './echarts-ui.vue';
import { computed, nextTick, watch } from 'vue';
import { usePreferences } from '@easyflow/preferences';
import {
tryOnUnmounted,
useDebounceFn,
useResizeObserver,
useTimeoutFn,
useWindowSize,
} from '@vueuse/core';
type EchartsUIType = typeof EchartsUI | undefined;
interface EchartsEngine {
init: (element: HTMLElement) => EChartsType;
}
function createUseEcharts(echarts: EchartsEngine) {
return function useEcharts(chartRef: Ref<EchartsUIType>) {
let chartInstance: EChartsType | null = null;
let cacheOptions: EChartsOption = {};
const { isDark } = usePreferences();
const { height, width } = useWindowSize();
const resizeHandler: () => void = useDebounceFn(resize, 200);
const getChartEl = (): HTMLElement | null => {
const refValue = chartRef?.value as unknown;
if (!refValue) return null;
if (refValue instanceof HTMLElement) {
return refValue;
}
const maybeComponent = refValue as { $el?: HTMLElement };
return maybeComponent.$el ?? null;
};
const isElHidden = (el: HTMLElement | null): boolean => {
if (!el) return true;
return el.offsetHeight === 0 || el.offsetWidth === 0;
};
const getOptions = computed((): EChartsOption => {
if (!isDark.value) {
return {};
}
return {
backgroundColor: 'transparent',
};
});
const initCharts = () => {
const el = chartRef?.value?.$el;
if (!el) {
return;
}
chartInstance = echarts.init(el);
return chartInstance;
};
const renderEcharts = (
options: EChartsOption,
clear = true,
): Promise<Nullable<EChartsType>> => {
cacheOptions = options;
const currentOptions = {
...options,
...getOptions.value,
};
return new Promise((resolve) => {
if (chartRef.value?.offsetHeight === 0) {
useTimeoutFn(async () => {
resolve(await renderEcharts(currentOptions));
}, 30);
return;
}
nextTick(() => {
const el = getChartEl();
if (isElHidden(el)) {
useTimeoutFn(async () => {
resolve(await renderEcharts(currentOptions));
}, 30);
return;
}
useTimeoutFn(() => {
if (!chartInstance) {
const instance = initCharts();
if (!instance) return;
}
clear && chartInstance?.clear();
chartInstance?.setOption(currentOptions);
resolve(chartInstance);
}, 30);
});
});
};
function resize() {
const el = getChartEl();
if (isElHidden(el)) {
return;
}
chartInstance?.resize({
animation: {
duration: 300,
easing: 'quadraticIn',
},
});
}
watch([width, height], () => {
resizeHandler?.();
});
useResizeObserver(chartRef as never, resizeHandler);
watch(isDark, () => {
if (chartInstance) {
chartInstance.dispose();
initCharts();
renderEcharts(cacheOptions);
resize();
}
});
tryOnUnmounted(() => {
chartInstance?.dispose();
});
return {
renderEcharts,
resize,
getChartInstance: () => chartInstance,
};
};
}
export { createUseEcharts };
export type { EchartsUIType };

View File

@@ -1,146 +1,7 @@
import type { EChartsOption } from 'echarts';
import type { Ref } from 'vue';
import type { Nullable } from '@easyflow/types';
import type EchartsUI from './echarts-ui.vue';
import { computed, nextTick, watch } from 'vue';
import { usePreferences } from '@easyflow/preferences';
import {
tryOnUnmounted,
useDebounceFn,
useResizeObserver,
useTimeoutFn,
useWindowSize,
} from '@vueuse/core';
import echarts from './echarts';
import { createUseEcharts } from './use-echarts-base';
type EchartsUIType = typeof EchartsUI | undefined;
function useEcharts(chartRef: Ref<EchartsUIType>) {
let chartInstance: echarts.ECharts | null = null;
let cacheOptions: EChartsOption = {};
const { isDark } = usePreferences();
const { height, width } = useWindowSize();
const resizeHandler: () => void = useDebounceFn(resize, 200);
const getChartEl = (): HTMLElement | null => {
const refValue = chartRef?.value as unknown;
if (!refValue) return null;
if (refValue instanceof HTMLElement) {
return refValue;
}
const maybeComponent = refValue as { $el?: HTMLElement };
return maybeComponent.$el ?? null;
};
const isElHidden = (el: HTMLElement | null): boolean => {
if (!el) return true;
return el.offsetHeight === 0 || el.offsetWidth === 0;
};
const getOptions = computed((): EChartsOption => {
if (!isDark.value) {
return {};
}
return {
backgroundColor: 'transparent',
};
});
const initCharts = () => {
const el = chartRef?.value?.$el;
if (!el) {
return;
}
chartInstance = echarts.init(el);
return chartInstance;
};
const renderEcharts = (
options: EChartsOption,
clear = true,
): Promise<Nullable<echarts.ECharts>> => {
cacheOptions = options;
const currentOptions = {
...options,
...getOptions.value,
};
return new Promise((resolve) => {
if (chartRef.value?.offsetHeight === 0) {
useTimeoutFn(async () => {
resolve(await renderEcharts(currentOptions));
}, 30);
return;
}
nextTick(() => {
const el = getChartEl();
if (isElHidden(el)) {
useTimeoutFn(async () => {
resolve(await renderEcharts(currentOptions));
}, 30);
return;
}
useTimeoutFn(() => {
if (!chartInstance) {
const instance = initCharts();
if (!instance) return;
}
clear && chartInstance?.clear();
chartInstance?.setOption(currentOptions);
resolve(chartInstance);
}, 30);
});
});
};
function resize() {
const el = getChartEl();
if (isElHidden(el)) {
return;
}
chartInstance?.resize({
animation: {
duration: 300,
easing: 'quadraticIn',
},
});
}
watch([width, height], () => {
resizeHandler?.();
});
useResizeObserver(chartRef as never, resizeHandler);
watch(isDark, () => {
if (chartInstance) {
chartInstance.dispose();
initCharts();
renderEcharts(cacheOptions);
resize();
}
});
tryOnUnmounted(() => {
// 销毁实例,释放资源
chartInstance?.dispose();
});
return {
renderEcharts,
resize,
getChartInstance: () => chartInstance,
};
}
const useEcharts = createUseEcharts(echarts);
export { useEcharts };
export type { EchartsUIType };
export type { EchartsUIType } from './use-echarts-base';

View File

@@ -0,0 +1,21 @@
import { LineChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { LabelLayout, UniversalTransition } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
LineChart,
TooltipComponent,
GridComponent,
LegendComponent,
LabelLayout,
UniversalTransition,
CanvasRenderer,
]);
export default echarts;

View File

@@ -0,0 +1,8 @@
import { createUseEcharts } from './use-echarts-base';
import workspaceEcharts from './workspace-echarts';
const useEcharts = createUseEcharts(workspaceEcharts);
export { default as EchartsUI } from './echarts-ui.vue';
export { useEcharts };
export type { EchartsUIType } from './use-echarts-base';