From 19059fde96b039dc59f63e57dbf98ea0f8299a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 22 Jul 2026 20:35:55 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E7=94=9F=E4=BA=A7=E5=8A=A0=E8=BD=BD=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图标与重型组件按需加载,优化菜单预取和页面请求链路 - 开启 gzip 与静态缓存并修复子路由 KeepAlive 冲突 --- easyflow-ui-admin/app/.env.production | 2 +- easyflow-ui-admin/app/src/preferences.ts | 2 +- .../__tests__/route-cache-safety.test.ts | 40 +++++ easyflow-ui-admin/app/src/router/access.ts | 32 +++- .../app/src/router/route-cache-safety.ts | 31 ++++ .../app/src/views/ai/model/Model.vue | 66 ++++++-- .../src/views/ai/workflow/WorkflowList.vue | 31 +++- .../src/views/dashboard/workspace/index.vue | 12 +- .../@core/base/icons/src/index.test.ts | 18 +++ .../packages/@core/base/icons/src/index.ts | 111 ++++++++++--- .../@core/base/icons/src/startup-icons.ts | 68 ++++++++ .../menu-ui/src/components/menu-item.vue | 1 + .../ui-kit/menu-ui/src/components/menu.vue | 23 +++ .../components/icon-picker/icon-picker.vue | 90 +++++++---- .../src/components/icon-picker/icons.ts | 4 +- .../effects/layouts/src/basic/layout.vue | 3 + .../effects/layouts/src/basic/menu/menu.vue | 6 + .../layouts/src/basic/menu/use-extra-menu.ts | 4 +- .../layouts/src/basic/menu/use-mixed-menu.ts | 23 ++- .../layouts/src/basic/menu/use-navigation.ts | 52 +++++- .../packages/effects/plugins/package.json | 4 + .../plugins/src/echarts/use-echarts-base.ts | 149 ++++++++++++++++++ .../plugins/src/echarts/use-echarts.ts | 145 +---------------- .../plugins/src/echarts/workspace-echarts.ts | 21 +++ .../effects/plugins/src/echarts/workspace.ts | 8 + easyflow-ui-admin/scripts/deploy/Dockerfile | 2 +- easyflow-ui-admin/scripts/deploy/nginx.conf | 35 +++- 27 files changed, 750 insertions(+), 233 deletions(-) create mode 100644 easyflow-ui-admin/app/src/router/__tests__/route-cache-safety.test.ts create mode 100644 easyflow-ui-admin/app/src/router/route-cache-safety.ts create mode 100644 easyflow-ui-admin/packages/@core/base/icons/src/index.test.ts create mode 100644 easyflow-ui-admin/packages/@core/base/icons/src/startup-icons.ts create mode 100644 easyflow-ui-admin/packages/effects/plugins/src/echarts/use-echarts-base.ts create mode 100644 easyflow-ui-admin/packages/effects/plugins/src/echarts/workspace-echarts.ts create mode 100644 easyflow-ui-admin/packages/effects/plugins/src/echarts/workspace.ts diff --git a/easyflow-ui-admin/app/.env.production b/easyflow-ui-admin/app/.env.production index a2730689..21169ed3 100644 --- a/easyflow-ui-admin/app/.env.production +++ b/easyflow-ui-admin/app/.env.production @@ -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 diff --git a/easyflow-ui-admin/app/src/preferences.ts b/easyflow-ui-admin/app/src/preferences.ts index 838b3074..2c43873b 100644 --- a/easyflow-ui-admin/app/src/preferences.ts +++ b/easyflow-ui-admin/app/src/preferences.ts @@ -25,7 +25,7 @@ export const overridesPreferences = defineOverridesPreferences({ transition: { enable: false, loading: false, - progress: false, + progress: true, }, }); diff --git a/easyflow-ui-admin/app/src/router/__tests__/route-cache-safety.test.ts b/easyflow-ui-admin/app/src/router/__tests__/route-cache-safety.test.ts new file mode 100644 index 00000000..987758cc --- /dev/null +++ b/easyflow-ui-admin/app/src/router/__tests__/route-cache-safety.test.ts @@ -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); + }); +}); diff --git a/easyflow-ui-admin/app/src/router/access.ts b/easyflow-ui-admin/app/src/router/access.ts index 7925bb68..5349bd38 100644 --- a/easyflow-ui-admin/app/src/router/access.ts +++ b/easyflow-ui-admin/app/src/router/access.ts @@ -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; + const meta = + route.meta && typeof route.meta === 'object' + ? (route.meta as Record) + : 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, diff --git a/easyflow-ui-admin/app/src/router/route-cache-safety.ts b/easyflow-ui-admin/app/src/router/route-cache-safety.ts new file mode 100644 index 00000000..b304bed1 --- /dev/null +++ b/easyflow-ui-admin/app/src/router/route-cache-safety.ts @@ -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 }; diff --git a/easyflow-ui-admin/app/src/views/ai/model/Model.vue b/easyflow-ui-admin/app/src/views/ai/model/Model.vue index 2614334f..7f220666 100644 --- a/easyflow-ui-admin/app/src/views/ai/model/Model.vue +++ b/easyflow-ui-admin/app/src/views/ai/model/Model.vue @@ -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; } diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue index ba70f342..d3a7c1a9 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue @@ -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" > - + + + + -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; diff --git a/easyflow-ui-admin/packages/@core/base/icons/src/index.test.ts b/easyflow-ui-admin/packages/@core/base/icons/src/index.test.ts new file mode 100644 index 00000000..970905ac --- /dev/null +++ b/easyflow-ui-admin/packages/@core/base/icons/src/index.test.ts @@ -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); + }); +}); diff --git a/easyflow-ui-admin/packages/@core/base/icons/src/index.ts b/easyflow-ui-admin/packages/@core/base/icons/src/index.ts index b1149792..fb208c85 100644 --- a/easyflow-ui-admin/packages/@core/base/icons/src/index.ts +++ b/easyflow-ui-admin/packages/@core/base/icons/src/index.ts @@ -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(); +const COLLECTION_LOADERS: Record< + LazyCollectionPrefix, + () => Promise +> = { + '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>(); -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(); + 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, }; diff --git a/easyflow-ui-admin/packages/@core/base/icons/src/startup-icons.ts b/easyflow-ui-admin/packages/@core/base/icons/src/startup-icons.ts new file mode 100644 index 00000000..27883fff --- /dev/null +++ b/easyflow-ui-admin/packages/@core/base/icons/src/startup-icons.ts @@ -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 = { + 'ant-design:apartment-outlined': { + width: 1024, + height: 1024, + body: '', + }, + 'ant-design:appstore-outlined': { + width: 1024, + height: 1024, + body: '', + }, + 'ep:arrow-right': { + width: 1024, + height: 1024, + body: '', + }, + 'ep:expand': { + width: 1024, + height: 1024, + body: '', + }, + 'ep:fold': { + width: 1024, + height: 1024, + body: '', + }, + 'mdi:chat-outline': { + width: 24, + height: 24, + body: '', + }, + 'mdi:clock-time-five-outline': { + width: 24, + height: 24, + body: '', + }, + 'mdi:github': { + width: 24, + height: 24, + body: '', + }, + 'mdi:hammer': { + width: 24, + height: 24, + body: '', + }, + 'mdi:home-outline': { + width: 24, + height: 24, + body: '', + }, + 'mdi:image-outline': { + width: 24, + height: 24, + body: '', + }, + 'mdi:keyboard-esc': { + width: 24, + height: 24, + body: '', + }, +}; diff --git a/easyflow-ui-admin/packages/@core/ui-kit/menu-ui/src/components/menu-item.vue b/easyflow-ui-admin/packages/@core/ui-kit/menu-ui/src/components/menu-item.vue index 3fe62a01..6f926520 100644 --- a/easyflow-ui-admin/packages/@core/ui-kit/menu-ui/src/components/menu-item.vue +++ b/easyflow-ui-admin/packages/@core/ui-kit/menu-ui/src/components/menu-item.vue @@ -91,6 +91,7 @@ onBeforeUnmount(() => {