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,7 @@ VITE_BASE=/flow/
VITE_GLOB_API_URL=/flow
# 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none
VITE_COMPRESS=gzip
# 是否开启 PWA
VITE_PWA=false

View File

@@ -25,7 +25,7 @@ export const overridesPreferences = defineOverridesPreferences({
transition: {
enable: false,
loading: false,
progress: false,
progress: true,
},
});

View File

@@ -0,0 +1,40 @@
import type { RouteRecordStringComponent } from '@easyflow/types';
import { describe, expect, it } from 'vitest';
import { disableUnsafeMenuRouteCaching } from '../route-cache-safety';
function createRoute(
path: string,
children?: RouteRecordStringComponent[],
): RouteRecordStringComponent {
return {
children,
component: '/example/Index',
meta: { keepAlive: true, title: path },
name: path,
path,
};
}
describe('route cache safety', () => {
it('disables stale keep-alive metadata for affected menus recursively', () => {
const knowledgeRoute = createRoute('/ai/documentCollection');
const routes = [
createRoute('/ai/workflow'),
createRoute('/ai', [knowledgeRoute]),
];
expect(disableUnsafeMenuRouteCaching(routes)).toBe(routes);
expect(routes[0]?.meta?.keepAlive).toBe(false);
expect(knowledgeRoute.meta?.keepAlive).toBe(false);
});
it('does not change unrelated route caching', () => {
const unrelatedRoute = createRoute('/example/cached');
disableUnsafeMenuRouteCaching([unrelatedRoute]);
expect(unrelatedRoute.meta?.keepAlive).toBe(true);
});
});

View File

@@ -4,6 +4,7 @@ import type {
} from '@easyflow/types';
import { generateAccessible } from '@easyflow/access';
import { loadMissingIconCollections } from '@easyflow/icons';
import { preferences } from '@easyflow/preferences';
import { ElMessage } from 'element-plus';
@@ -12,8 +13,35 @@ import { getAllMenusApi } from '#/api';
import { BasicLayout, IFrameView } from '#/layouts';
import { $t } from '#/locales';
import { disableUnsafeMenuRouteCaching } from './route-cache-safety';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
function collectMenuIcons(routes: unknown[]): string[] {
const icons: string[] = [];
routes.forEach((item) => {
if (!item || typeof item !== 'object') {
return;
}
const route = item as Record<string, unknown>;
const meta =
route.meta && typeof route.meta === 'object'
? (route.meta as Record<string, unknown>)
: undefined;
[route.icon, route.activeIcon, meta?.icon, meta?.activeIcon].forEach(
(icon) => {
if (typeof icon === 'string') {
icons.push(icon);
}
},
);
if (Array.isArray(route.children)) {
icons.push(...collectMenuIcons(route.children));
}
});
return icons;
}
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
@@ -29,7 +57,9 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
duration: 1500,
message: `${$t('common.loadingMenu')}...`,
});
return await getAllMenusApi();
const menuRoutes = await getAllMenusApi();
await loadMissingIconCollections(collectMenuIcons(menuRoutes));
return disableUnsafeMenuRouteCaching(menuRoutes);
},
// 可以指定没有权限跳转403页面
forbiddenComponent,

View File

@@ -0,0 +1,31 @@
import type { RouteRecordStringComponent } from '@easyflow/types';
const UNSAFE_KEEP_ALIVE_MENU_PATHS = new Set([
'/ai/agents',
'/ai/documentCollection',
'/ai/model',
'/ai/skill',
'/ai/workflow',
'/dashboard/workspace',
'/sys/sysAccount',
]);
/**
* Disable route-level caching that conflicts with independent hidden child
* routes. Assigning false also replaces stale true values in persisted tabs.
*/
function disableUnsafeMenuRouteCaching(
routes: RouteRecordStringComponent[],
): RouteRecordStringComponent[] {
for (const route of routes) {
if (UNSAFE_KEEP_ALIVE_MENU_PATHS.has(route.path) && route.meta) {
route.meta.keepAlive = false;
}
if (route.children?.length) {
disableUnsafeMenuRouteCaching(route.children);
}
}
return routes;
}
export { disableUnsafeMenuRouteCaching };

View File

@@ -213,7 +213,7 @@ const loadProviderDetail = async (
if (!options.keepDraft) {
syncProviderDraft();
}
return;
return undefined;
}
isDetailLoading.value = true;
@@ -234,30 +234,64 @@ const loadProviderDetail = async (
if (!options.keepDraft) {
syncProviderDraft(selectedProvider.value);
}
return res.data;
} else {
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
}
} finally {
isDetailLoading.value = false;
}
return undefined;
};
const refreshProviderCounts = async (list: any[]) => {
const entries = await Promise.all(
list.map(async (provider) => {
let providerCountRequestId = 0;
const PROVIDER_COUNT_CONCURRENCY = 2;
const refreshProviderCounts = async (
list: any[],
excludedProviderId?: string,
) => {
const currentRequestId = ++providerCountRequestId;
const pendingProviders = list.filter(
(provider) => String(provider.id) !== excludedProviderId,
);
const entries: Array<[string, number]> = [];
let cursor = 0;
const worker = async () => {
while (cursor < pendingProviders.length) {
const provider = pendingProviders[cursor++];
const providerId = String(provider.id);
try {
const res = await api.get(
`/api/v1/model/getList?providerId=${provider.id}`,
`/api/v1/model/getList?providerId=${providerId}`,
{},
);
return [provider.id, res.errorCode === 0 ? countModels(res.data) : 0];
entries.push([
providerId,
res.errorCode === 0 ? countModels(res.data) : 0,
]);
} catch {
return [provider.id, 0];
entries.push([providerId, 0]);
}
}),
}
};
await Promise.all(
Array.from(
{
length: Math.min(PROVIDER_COUNT_CONCURRENCY, pendingProviders.length),
},
worker,
),
);
providerCounts.value = Object.fromEntries(entries);
if (currentRequestId === providerCountRequestId) {
providerCounts.value = {
...providerCounts.value,
...Object.fromEntries(entries),
};
}
};
const loadProviders = async (
@@ -272,12 +306,15 @@ const loadProviders = async (
const res = await getLlmProviderList();
const list = res?.data || [];
providers.value = list;
if (refreshCounts) {
await refreshProviderCounts(list);
}
providerCounts.value = Object.fromEntries(
list.map((provider: any) => [
String(provider.id),
providerCounts.value[String(provider.id)] ?? 0,
]),
);
if (list.length === 0) {
providerCountRequestId += 1;
selectedProviderId.value = '';
syncProviderDraft();
syncGroupedModels({});
@@ -300,6 +337,9 @@ const loadProviders = async (
await loadProviderDetail(nextProviderId, {
keepDraft: keepDraft && previousId === nextProviderId,
});
if (refreshCounts) {
void refreshProviderCounts(list, String(nextProviderId));
}
} finally {
isPageLoading.value = false;
}

View File

@@ -7,9 +7,14 @@ import type {
} from '#/components/page/CardList.vue';
import type { OfflineImpactCheck } from '#/views/ai/shared/offline-impact';
import { computed, h, markRaw, onMounted, ref } from 'vue';
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
import {
computed,
defineAsyncComponent,
h,
markRaw,
onMounted,
ref,
} from 'vue';
import { useAccess } from '@easyflow/access';
import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -40,6 +45,7 @@ import {
ElMessage,
ElMessageBox,
ElPopover,
ElSkeleton,
} from 'element-plus';
import { tryit } from 'radash';
@@ -67,6 +73,10 @@ import {
import WorkflowModal from './WorkflowModal.vue';
const ElXMarkdown = defineAsyncComponent(
() => import('vue-element-plus-x/es/XMarkdown/index.js'),
);
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
interface FieldDefinition {
@@ -1007,11 +1017,16 @@ function handleHeaderButtonClick(data: any) {
class="workflow-api-markdown-wrap"
@click="handleApiDocClick"
>
<Suspense>
<ElXMarkdown
:markdown="apiDocMarkdown"
:allow-html="true"
:sanitize="false"
/>
<template #fallback>
<ElSkeleton :rows="6" animated />
</template>
</Suspense>
</div>
</ElDialog>
<HeaderSearch

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@easyflow/plugins/echarts';
import type { EchartsUIType } from '@easyflow/plugins/echarts/workspace';
import type {
DashboardAssistantTrendSeries,
@@ -24,7 +24,7 @@ import {
} from 'vue';
import { AnalysisChartCard } from '@easyflow/common-ui';
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts/workspace';
import { useUserStore } from '@easyflow/stores';
import { convertToRgb, downloadFileFromBlob } from '@easyflow/utils';
@@ -307,17 +307,15 @@ async function loadOverview() {
errorMessage.value = '';
try {
const [data] = await Promise.all([
getDashboardOverview(buildOverviewQuery()),
loadAssistantOptions().catch(() => undefined),
]);
void loadAssistantOptions().catch(() => undefined);
const data = await getDashboardOverview(buildOverviewQuery());
overview.value = data;
resetAssistantTrendSelection();
await renderCharts();
if (data.chatStatus?.available === false) {
resetUserRanks();
} else {
await loadUserRanks();
void loadUserRanks();
}
} catch (error) {
overview.value = null;

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">

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,18 +80,9 @@ const currentPage = ref(1);
const keyword = ref('');
const keywordDebounce = refDebounced(keyword, 300);
const innerIcons = ref<string[]>([]);
let iconLoadRequestId = 0;
/* 当检索关键词变化时,重置分页 */
watch(keywordDebounce, () => {
currentPage.value = 1;
setCurrentPage(1);
});
watchDebounced(
() => props.prefix,
async (prefix) => {
if (prefix && prefix !== 'svg' && props.autoFetchApi) {
innerIcons.value = [
const commonSvgIcons = [
'svg:talk',
'svg:plugin',
'svg:workflow',
@@ -107,11 +103,47 @@ watchDebounced(
'svg:user-feedback',
'svg:oauth',
'svg:mcp',
...getLocalIconsData(prefix),
];
/* 当检索关键词变化时,重置分页 */
watch(keywordDebounce, () => {
currentPage.value = 1;
setCurrentPage(1);
});
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,9 +101,11 @@ function useMixedMenu() {
if (!willOpenedByWindow(key)) {
rootMenuPath.value = rootMenu?.path ?? '';
splitSideMenus.value = _splitSideMenus;
_splitSideMenus.forEach((menu) => {
_splitSideMenus
.slice(0, MAX_AUTOMATIC_PREFETCH_COUNT)
.forEach((menu, index) => {
if (menu.path) {
prefetch(menu.path);
schedulePrefetch(menu.path, PREFETCH_INTERVAL * (index + 1));
}
});
}
@@ -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';

View File

@@ -22,7 +22,7 @@ RUN rm -f /etc/nginx/conf.d/default.conf
COPY --from=builder /app/app/dist/ /usr/share/nginx/html/flow/
COPY scripts/deploy/nginx.conf /etc/nginx/nginx.conf
RUN chown -R nginx:nginx /usr/share/nginx/html
RUN nginx -t && chown -R nginx:nginx /usr/share/nginx/html
EXPOSE 80

View File

@@ -12,6 +12,22 @@ http {
keepalive_timeout 65;
client_max_body_size 500m;
gzip on;
gzip_static on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
application/javascript
application/json
application/xml
application/wasm
image/svg+xml
text/css
text/plain
text/xml;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
@@ -90,9 +106,26 @@ http {
proxy_redirect off;
}
location ^~ /flow/ {
location = /flow/index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
location = /flow/_app.config.js {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
location ~* ^/flow/(?:avif|css|eot|gif|ico|jpeg|jpg|js|jse|png|svg|ttf|wasm|webp|woff|woff2)/ {
root /usr/share/nginx/html;
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location /flow/ {
root /usr/share/nginx/html;
try_files $uri $uri/ /flow/index.html;
add_header Cache-Control "no-cache" always;
}
}
}