发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
27 changed files with 750 additions and 233 deletions
Showing only changes of commit 19059fde96 - Show all commits

View File

@@ -4,7 +4,7 @@ VITE_BASE=/flow/
VITE_GLOB_API_URL=/flow VITE_GLOB_API_URL=/flow
# 是否开启压缩,可以设置为 none, brotli, gzip # 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none VITE_COMPRESS=gzip
# 是否开启 PWA # 是否开启 PWA
VITE_PWA=false VITE_PWA=false

View File

@@ -25,7 +25,7 @@ export const overridesPreferences = defineOverridesPreferences({
transition: { transition: {
enable: false, enable: false,
loading: 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'; } from '@easyflow/types';
import { generateAccessible } from '@easyflow/access'; import { generateAccessible } from '@easyflow/access';
import { loadMissingIconCollections } from '@easyflow/icons';
import { preferences } from '@easyflow/preferences'; import { preferences } from '@easyflow/preferences';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
@@ -12,8 +13,35 @@ import { getAllMenusApi } from '#/api';
import { BasicLayout, IFrameView } from '#/layouts'; import { BasicLayout, IFrameView } from '#/layouts';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { disableUnsafeMenuRouteCaching } from './route-cache-safety';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue'); 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) { async function generateAccess(options: GenerateMenuAndRoutesOptions) {
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue'); const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
@@ -29,7 +57,9 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
duration: 1500, duration: 1500,
message: `${$t('common.loadingMenu')}...`, message: `${$t('common.loadingMenu')}...`,
}); });
return await getAllMenusApi(); const menuRoutes = await getAllMenusApi();
await loadMissingIconCollections(collectMenuIcons(menuRoutes));
return disableUnsafeMenuRouteCaching(menuRoutes);
}, },
// 可以指定没有权限跳转403页面 // 可以指定没有权限跳转403页面
forbiddenComponent, 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) { if (!options.keepDraft) {
syncProviderDraft(); syncProviderDraft();
} }
return; return undefined;
} }
isDetailLoading.value = true; isDetailLoading.value = true;
@@ -234,30 +234,64 @@ const loadProviderDetail = async (
if (!options.keepDraft) { if (!options.keepDraft) {
syncProviderDraft(selectedProvider.value); syncProviderDraft(selectedProvider.value);
} }
return res.data;
} else { } else {
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed')); ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
} }
} finally { } finally {
isDetailLoading.value = false; isDetailLoading.value = false;
} }
return undefined;
}; };
const refreshProviderCounts = async (list: any[]) => { let providerCountRequestId = 0;
const entries = await Promise.all( const PROVIDER_COUNT_CONCURRENCY = 2;
list.map(async (provider) => {
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 { try {
const res = await api.get( 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 { } 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 ( const loadProviders = async (
@@ -272,12 +306,15 @@ const loadProviders = async (
const res = await getLlmProviderList(); const res = await getLlmProviderList();
const list = res?.data || []; const list = res?.data || [];
providers.value = list; providers.value = list;
providerCounts.value = Object.fromEntries(
if (refreshCounts) { list.map((provider: any) => [
await refreshProviderCounts(list); String(provider.id),
} providerCounts.value[String(provider.id)] ?? 0,
]),
);
if (list.length === 0) { if (list.length === 0) {
providerCountRequestId += 1;
selectedProviderId.value = ''; selectedProviderId.value = '';
syncProviderDraft(); syncProviderDraft();
syncGroupedModels({}); syncGroupedModels({});
@@ -300,6 +337,9 @@ const loadProviders = async (
await loadProviderDetail(nextProviderId, { await loadProviderDetail(nextProviderId, {
keepDraft: keepDraft && previousId === nextProviderId, keepDraft: keepDraft && previousId === nextProviderId,
}); });
if (refreshCounts) {
void refreshProviderCounts(list, String(nextProviderId));
}
} finally { } finally {
isPageLoading.value = false; isPageLoading.value = false;
} }

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { EchartsUIType } from '@easyflow/plugins/echarts'; import type { EchartsUIType } from '@easyflow/plugins/echarts/workspace';
import type { import type {
DashboardAssistantTrendSeries, DashboardAssistantTrendSeries,
@@ -24,7 +24,7 @@ import {
} from 'vue'; } from 'vue';
import { AnalysisChartCard } from '@easyflow/common-ui'; 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 { useUserStore } from '@easyflow/stores';
import { convertToRgb, downloadFileFromBlob } from '@easyflow/utils'; import { convertToRgb, downloadFileFromBlob } from '@easyflow/utils';
@@ -307,17 +307,15 @@ async function loadOverview() {
errorMessage.value = ''; errorMessage.value = '';
try { try {
const [data] = await Promise.all([ void loadAssistantOptions().catch(() => undefined);
getDashboardOverview(buildOverviewQuery()), const data = await getDashboardOverview(buildOverviewQuery());
loadAssistantOptions().catch(() => undefined),
]);
overview.value = data; overview.value = data;
resetAssistantTrendSelection(); resetAssistantTrendSelection();
await renderCharts(); await renderCharts();
if (data.chatStatus?.available === false) { if (data.chatStatus?.available === false) {
resetUserRanks(); resetUserRanks();
} else { } else {
await loadUserRanks(); void loadUserRanks();
} }
} catch (error) { } catch (error) {
overview.value = null; 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 { import type {
IconifyIcon as IconifyIconData, IconifyIcon as IconifyIconData,
IconifyJSON, IconifyJSON,
} from '@iconify/vue/offline'; } 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 { 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 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) { if (prefix === false) {
return ''; 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. * 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); addOfflineCollection(data, prefix);
const iconPrefix = getCollectionPrefix(data, prefix); const iconPrefix = getCollectionPrefix(data, prefix);
Object.keys(data.icons).forEach((name) => { Object.keys(data.icons).forEach((name) => {
@@ -62,12 +81,66 @@ function isLocalIcon(name: string) {
return LOCAL_ICON_NAMES.has(name); return LOCAL_ICON_NAMES.has(name);
} }
registerCollection(antDesign); function isLazyCollectionPrefix(
registerCollection(ep); prefix: string,
registerCollection(mdi); ): prefix is LazyCollectionPrefix {
Object.entries(LOCAL_ICON_DATA).forEach(([name, data]) => { return Object.prototype.hasOwnProperty.call(COLLECTION_LOADERS, prefix);
registerIcon(name, data); }
/**
* 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'; export * from './create-icon';
@@ -75,9 +148,11 @@ export * from './lucide';
export type { IconifyIcon as IconifyIconStructure } from '@iconify/vue/offline'; export type { IconifyIcon as IconifyIconStructure } from '@iconify/vue/offline';
export { export {
registerCollection as addCollection,
registerIcon as addIcon,
IconifyIcon, IconifyIcon,
isLocalIcon, isLocalIcon,
listIcons, listIcons,
registerCollection as addCollection, loadIconCollection,
registerIcon as addIcon, 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> </script>
<template> <template>
<li <li
:data-menu-path="path"
:class="[ :class="[
rootMenu.theme, rootMenu.theme,
b(), b(),

View File

@@ -50,6 +50,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ const emit = defineEmits<{
close: [string, string[]]; close: [string, string[]];
enter: [string];
open: [string, string[]]; open: [string, string[]];
select: [string, string[]]; select: [string, string[]];
}>(); }>();
@@ -257,6 +258,25 @@ function handleMenuItemClick(data: MenuItemClicked) {
emit('select', path, parentPaths); 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) { function handleSubMenuClick({ parentPaths, path }: MenuItemRegistered) {
const isOpened = openedMenus.value.includes(path); const isOpened = openedMenus.value.includes(path);
@@ -349,6 +369,9 @@ function getActivePaths() {
]" ]"
:style="menuStyle" :style="menuStyle"
role="menu" role="menu"
@focusin="handleMenuItemEnter"
@mouseleave="enteredPath = ''"
@mouseover="handleMenuItemEnter"
> >
<template v-if="mode === 'horizontal' && getSlot.showSlotMore"> <template v-if="mode === 'horizontal' && getSlot.showSlotMore">
<template v-for="item in getSlot.slotDefault" :key="item.key"> <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 { computed, ref, useAttrs, watch, watchEffect } from 'vue';
import { usePagination } from '@easyflow/hooks'; 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 { $t } from '@easyflow/locales';
import { import {
@@ -75,18 +80,9 @@ const currentPage = ref(1);
const keyword = ref(''); const keyword = ref('');
const keywordDebounce = refDebounced(keyword, 300); const keywordDebounce = refDebounced(keyword, 300);
const innerIcons = ref<string[]>([]); const innerIcons = ref<string[]>([]);
let iconLoadRequestId = 0;
/* 当检索关键词变化时,重置分页 */ const commonSvgIcons = [
watch(keywordDebounce, () => {
currentPage.value = 1;
setCurrentPage(1);
});
watchDebounced(
() => props.prefix,
async (prefix) => {
if (prefix && prefix !== 'svg' && props.autoFetchApi) {
innerIcons.value = [
'svg:talk', 'svg:talk',
'svg:plugin', 'svg:plugin',
'svg:workflow', 'svg:workflow',
@@ -107,11 +103,47 @@ watchDebounced(
'svg:user-feedback', 'svg:user-feedback',
'svg:oauth', 'svg:oauth',
'svg:mcp', '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(() => { const currentList = computed(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,37 @@ import { useRouter } from 'vue-router';
import { isHttpUrl, openRouteInNewWindow, openWindow } from '@easyflow/utils'; 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() { function useNavigation() {
const router = useRouter(); const router = useRouter();
const routeMetaMap = new Map<string, RouteRecordNormalized>(); const routeMetaMap = new Map<string, RouteRecordNormalized>();
const prefetchedPaths = new Set<string>(); const prefetchedPaths = new Set<string>();
const prefetchingPaths = new Set<string>(); const prefetchingPaths = new Set<string>();
const scheduledPrefetches = new Map<string, ReturnType<typeof setTimeout>>();
// 初始化路由映射 // 初始化路由映射
const initRouteMetaMap = () => { const initRouteMetaMap = () => {
@@ -40,6 +66,12 @@ function useNavigation() {
}; };
const prefetch = (path: string) => { const prefetch = (path: string) => {
const scheduledPrefetch = scheduledPrefetches.get(path);
if (scheduledPrefetch) {
clearTimeout(scheduledPrefetch);
scheduledPrefetches.delete(path);
}
if ( if (
isHttpUrl(path) || isHttpUrl(path) ||
prefetchedPaths.has(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) => { const navigation = async (path: string) => {
try { try {
const route = routeMetaMap.get(path); const route = routeMetaMap.get(path);
@@ -116,7 +166,7 @@ function useNavigation() {
return shouldOpenInNewWindow(path); return shouldOpenInNewWindow(path);
}; };
return { navigation, prefetch, willOpenedByWindow }; return { navigation, prefetch, schedulePrefetch, willOpenedByWindow };
} }
export { useNavigation }; export { useNavigation };

View File

@@ -11,6 +11,10 @@
"types": "./src/echarts/index.ts", "types": "./src/echarts/index.ts",
"default": "./src/echarts/index.ts" "default": "./src/echarts/index.ts"
}, },
"./echarts/workspace": {
"types": "./src/echarts/workspace.ts",
"default": "./src/echarts/workspace.ts"
},
"./vxe-table": { "./vxe-table": {
"types": "./src/vxe-table/index.ts", "types": "./src/vxe-table/index.ts",
"default": "./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 echarts from './echarts';
import { createUseEcharts } from './use-echarts-base';
type EchartsUIType = typeof EchartsUI | undefined; const useEcharts = createUseEcharts(echarts);
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,
};
}
export { useEcharts }; export { useEcharts };
export type { EchartsUIType } from './use-echarts-base';
export type { EchartsUIType };

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 --from=builder /app/app/dist/ /usr/share/nginx/html/flow/
COPY scripts/deploy/nginx.conf /etc/nginx/nginx.conf 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 EXPOSE 80

View File

@@ -12,6 +12,22 @@ http {
keepalive_timeout 65; keepalive_timeout 65;
client_max_body_size 500m; 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 { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
'' close; '' close;
@@ -90,9 +106,26 @@ http {
proxy_redirect off; 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; root /usr/share/nginx/html;
try_files $uri $uri/ /flow/index.html; try_files $uri $uri/ /flow/index.html;
add_header Cache-Control "no-cache" always;
} }
} }
} }