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"
>
<ElXMarkdown
:markdown="apiDocMarkdown"
:allow-html="true"
:sanitize="false"
/>
<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;